From 58f2b885fe6b9dbc103910b625b5ca4726b106b5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:38:32 +0300 Subject: [PATCH 01/96] Share the surface family rule, and make its Swift compile for watchOS Three preliminaries for generating watch complications, each of which stands on its own. A result entry's role suffix now survives into the copied artifact name. Every entry used to land on target/, keyed on the extension alone, so a build returning two artifacts of the same kind -- a phone APK and a companion Wear APK beside it -- collapsed both onto one path and the last one written won. That corrupts the primary artifact, not merely the secondary one, and it does it silently. The family classification moves into SurfaceKindFamilies, which also reads the portable "families" key with "iosFamilies" as its legacy spelling. The Android builder has to tell a home-screen kind from a complication kind and is not going to parse a key with "ios" in its name. Delegation rather than a second copy, because the rule is subtle enough that three call sites once implemented it as startsWith("watch") and all three got accessoryCircular wrong. The shared surfaces Swift now compiles for watchOS. Four WidgetKit system families are @available(watchOS, unavailable) -- unnameable, not merely absent -- and UIColor.systemBackground, UIColor(dynamicProvider:) and UIGraphicsImageRenderer are all API_UNAVAILABLE(watchos); the file named all of them unconditionally. The substitutes are the right answers rather than degradations: a watch face composites over black and has no light appearance, so the background role is black and a light/dark pair resolves to its dark half. Images downsample through ImageIO, which decodes at the target size so the full-size bitmap is never resident, at a quarter of the phone's ceiling. Verified by typechecking the sources against both the watchOS and iOS SDKs. SurfacesSwiftWatchPortabilityTest is the half that also runs on a CI leg with no Xcode; it was confirmed to fail when the systemBackground guard is removed. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 12 +- .../com/codename1/maven/CN1BuildMojo.java | 50 ++++- .../util/IOSWidgetExtensionBuilder.java | 49 +---- .../codename1/util/SurfaceKindFamilies.java | 179 ++++++++++++++++++ .../surfaces/ios/CN1DescriptorWidget.swift | 19 +- .../surfaces/ios/CN1SurfaceModel.swift | 14 ++ .../surfaces/ios/CN1SurfaceRenderer.swift | 34 +++- .../maven/CN1BuildResultArtifactRoleTest.java | 53 ++++++ .../util/SurfaceKindFamiliesTest.java | 140 ++++++++++++++ .../SurfacesSwiftWatchPortabilityTest.java | 177 +++++++++++++++++ 10 files changed, 671 insertions(+), 56 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/util/SurfaceKindFamilies.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildResultArtifactRoleTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/util/SurfaceKindFamiliesTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/util/SurfacesSwiftWatchPortabilityTest.java 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..4efc20b0cd8 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 @@ -6547,13 +6547,11 @@ 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); + if (!families.isEmpty()) { kind.setIosFamilies(families); } surfacesKinds.add(kind); 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..600857a686c 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 @@ -1737,9 +1737,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 = roleSuffixOf(base); + 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 +2472,41 @@ 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.

+ */ + private static final String[] ARTIFACT_ROLE_SUFFIXES = {"-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..99446ce7939 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 @@ -507,30 +507,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) { @@ -618,29 +599,11 @@ public boolean hasIosSurface() { } public static boolean isWatchOnly(Kind kind) { - List families = kind.getIosFamilies(); - if (families == null || families.isEmpty()) { - return false; - } - for (String family : families) { - if (family != null && !normalizeFamily(family).startsWith("watch")) { - return false; - } - } - return true; + return SurfaceKindFamilies.isWatchOnly(kind.getIosFamilies()); } public static boolean hasWatchFamily(Kind kind) { - List families = kind.getIosFamilies(); - if (families == null) { - return false; - } - for (String family : families) { - if (family != null && normalizeFamily(family).startsWith("watch")) { - return true; - } - } - return false; + 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..b16b41943ad --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/SurfaceKindFamilies.java @@ -0,0 +1,179 @@ +/* + * 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; {@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(); + } + Object declared = kindJson.get("families"); + if (!(declared instanceof List)) { + declared = kindJson.get("iosFamilies"); + } + if (!(declared instanceof List)) { + return Collections.emptyList(); + } + 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; + } + + /** + * Whether one declared family is a watch complication. + * + * @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 && normalize(family).startsWith("watch"); + } + + /** + * 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..945bc06d211 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 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/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..298b75bdfab --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildResultArtifactRoleTest.java @@ -0,0 +1,53 @@ +/* + * 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)); + } +} 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..e377850699f --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/SurfaceKindFamiliesTest.java @@ -0,0 +1,140 @@ +/* + * 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.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)); + } + + @Test + void nullsAreTolerated() { + assertEquals(0, SurfaceKindFamilies.read(null).size()); + assertFalse(SurfaceKindFamilies.hasWatchFamily(null)); + assertFalse(SurfaceKindFamilies.isWatchOnly(null)); + assertFalse(SurfaceKindFamilies.isWatch(null)); + } +} 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"); + } +} From 9e54822932cfc390233e7e93357fcc2a8be5b79e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:43:53 +0300 Subject: [PATCH 02/96] Give the widget extension a watchOS flavour setWatchTarget(true) builds a second WidgetKit extension for the watch app, where the first one is built for the phone. They share every Swift source that can be shared and differ in which families they may name. The dead watchTarget parameter that has been threaded through familiesSwift, watchOnlyFamiliesSwift and mapFamily since the families were introduced finally has a caller -- but it was not correct as written. With watchTarget true it still mapped small/medium/large onto .systemSmall and friends, which are @available(watchOS, unavailable): unnameable there, not merely absent, so the watch bundle would have failed to compile rather than showing a widget nobody wanted. Those four and lockscreen now resolve to no family in a watch target, and the home-screen fallback for a kind with no usable family is suppressed there too. In the other direction accessoryCorner needs no os(watchOS) guard inside a target whose SUPPORTED_PLATFORMS is watchOS alone. The rest follows the same split: the two ActivityKit sources are never shipped to the watch and the live activity never joins its bundle, the widget-count limit counts the kinds this flavour actually hosts, and the build settings describe a watch target -- WATCHOS_DEPLOYMENT_TARGET, SDKROOT, device family 4, arm64_32 -- with ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES off, because the watch app already embeds the runtime for everything nested inside it. The floor is watchOS 10.0, not WidgetKit's own 9.0: every generated widget applies containerBackground(for:), which is watchOS 10, so a lower target does not lose the background -- it fails the build. A lower one is refused with that reason. Verified by generating a watch extension from a mixed manifest and typechecking the whole thing against the watchOS 26.2 SDK, and by generating the iOS one from the same manifest and typechecking it against the iOS SDK. Co-Authored-By: Claude Opus 5 (1M context) --- .../util/IOSWidgetExtensionBuilder.java | 254 +++++++++++++++--- .../IOSWidgetExtensionWatchFamilyTest.java | 8 +- .../IOSWidgetExtensionWatchTargetTest.java | 209 ++++++++++++++ 3 files changed, 427 insertions(+), 44 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWidgetExtensionWatchTargetTest.java 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 99446ce7939..a8d4593354e 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,31 @@ 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. + * + *

Not WidgetKit's own watchOS 9 floor: {@code containerBackground(for:)}, which every + * generated widget applies, is watchOS 10. Below 10.0 the availability check around it + * stops compiling, so 9.0 would not merely lose the background -- it would fail the + * build.

+ */ + public static final String WATCH_MIN_DEPLOYMENT_TARGET = "10.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 +164,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. */ @@ -175,6 +209,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 +241,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 +250,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 +260,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 +322,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 + ": containerBackground(for:), which every generated " + + "widget applies, is watchOS " + WATCH_MIN_DEPLOYMENT_TARGET + + " and its availability check does not compile below that"); + } for (Kind kind : kinds) { if (kind.getId() == null || !isKindId(kind.getId())) { throw new IllegalStateException("widget kind ids must match [a-z][a-z0-9_]*: " @@ -375,9 +443,23 @@ 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"); + 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"); @@ -411,22 +493,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 +518,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 +553,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 +574,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 ""; @@ -519,6 +621,18 @@ 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; + } + } if ("small".equals(family) || "systemSmall".equals(family)) { return ".systemSmall"; } @@ -570,7 +684,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 @@ -598,6 +712,66 @@ public boolean hasIosSurface() { 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; + } + } + 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 0; + } + + private static int parsePart(String[] parts, int index) { + if (index >= parts.length) { + return 0; + } + try { + return Integer.parseInt(parts[index].trim()); + } catch (NumberFormatException ex) { + return 0; + } + } + public static boolean isWatchOnly(Kind kind) { return SurfaceKindFamilies.isWatchOnly(kind.getIosFamilies()); } 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..34d3e7b960d --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWidgetExtensionWatchTargetTest.java @@ -0,0 +1,209 @@ +/* + * 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.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); + } + + /// 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); + } + + /// 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=10.0"), props); + assertTrue(props.contains("SDKROOT=watchos"), props); + assertTrue(props.contains("SUPPORTED_PLATFORMS=watchos watchsimulator"), props); + assertTrue(props.contains("TARGETED_DEVICE_FAMILY=4"), props); + 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()")); + } + + /// containerBackground(for:) is watchOS 10, and every generated widget applies it. Below + /// that floor the availability check around it stops compiling, so a lower target does not + /// merely lose the background -- it fails the build. + @Test + void aDeploymentTargetBelowTheWatchFloorIsRejected() { + final IOSWidgetExtensionBuilder b = watchBuilder("watchCircular").setDeploymentTarget("9.0"); + + IllegalStateException ex = assertThrows(IllegalStateException.class, new Executable() { + public void execute() throws Throwable { + b.buildFileMap(); + } + }); + assertTrue(ex.getMessage().contains("containerBackground"), ex.getMessage()); + } + + /// "10.0" orders above "9.0" only under a numeric comparison; string order says otherwise. + @Test + void theFloorCheckComparesVersionsNumerically() throws IOException { + assertEquals("10.0", watchBuilder("watchCircular").getDeploymentTarget()); + watchBuilder("watchCircular").setDeploymentTarget("11.2").buildFileMap(); + } +} From a647275c4e8823cc0229097fb61e2fbf72f1e969 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:47:14 +0300 Subject: [PATCH 03/96] Let the watch publish its own surfaces CN1_USE_WIDGETS was undone for watchOS alongside tvOS, so every surfaces native compiled to its unsupported stub and Surfaces.publish() from a watch app was a hard no-op that reported success. tvOS keeps the undef -- it has no WidgetKit at all -- but the watch does not: a complication is a WidgetKit widget in an accessory family, hosted by the watch app's own extension and fed from the watch's own App Group container. That container is the counter-intuitive part and is now written down where the guard used to be. The identifier is the same string as the phone's; the container behind it is a separate directory on the watch. So the watch has to publish for itself rather than reading what the phone wrote, which is why restoring these natives is what makes a complication possible at all. cn1SurfacesMinOSSupported compares the plist floor against the OS actually running, so its fallback has to be per-platform too. The iOS default of 16.1 compared against a watchOS version is never met, and every watch would have reported no widget support whatever the plist said. The four ActivityKit natives now answer for the watch explicitly instead of relying on the Swift bridge having compiled its bodies out. They keep their symbols -- the Java methods are reachable from shared code, so removing them would fail the watch link rather than tree-shake -- and the guard uses #else rather than an early return so the watch slice compiles no unreachable statement. Verified by compiling the surfaces native block against both the watchOS and iOS SDKs, and by typechecking the app-target Swift glue against the watchOS SDK. Co-Authored-By: Claude Opus 5 (1M context) --- .../CodenameOne_GLViewController.h | 13 ++++- Ports/iOSPort/nativeSources/IOSNative.m | 56 +++++++++++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index 7a2c7f6df68..723719ffde3 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -169,8 +169,17 @@ 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 diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 1207524ac5b..c3a654f324a 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -15302,23 +15302,39 @@ 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]; } @@ -15361,6 +15377,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 +15400,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 +15422,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,6 +15445,7 @@ void com_codename1_impl_ios_IOSNative_surfacesEndActivity___java_lang_String_jav } POOL_END(); } +#endif } JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesWidgetsSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { @@ -15420,6 +15460,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 +15480,7 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesActivitiesSupported__(CN1_ return supported ? JAVA_TRUE : JAVA_FALSE; } return JAVA_FALSE; +#endif } #else // CN1_USE_WIDGETS From 7a3505a5dd92352804056fdbace896e090e19312 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:21:24 +0300 Subject: [PATCH 04/96] Build the watch a complication extension of its own The watch app now gets a CN1WatchWidgets target embedded in its own PlugIns folder, and that single choice is what makes both distributions need no separate handling: the companion case already copies the finished watch app into the phone app with the .appex inside it, and the platform filter keeping the watch tree out of the Mac Catalyst slice covers the extension for free; the standalone case ships the watch app as the product. There is no branch for either. The target type is :app_extension. :watch2_extension is the legacy paired WatchKit app extension -- the same trap as :application versus :watch2_app for the app target -- while a WidgetKit extension is a plain app extension wherever Apple ships it. Generating it belongs here rather than beside the iOS extension because the watch app target does not exist yet when the schemes ruby runs. So it is written immediately before the watch builder's own xcodeproj script and wired by that. Two things the watch target could not previously reach. Its own translation carries no CN1SurfaceBridge -- only the phone's -src does -- so the natives found no bridge through NSClassFromString and answered unsupported; the bridge and its config constant are now added to the watch target by name, de-duped so the shared-translation case is unaffected. And the entitlements file was gated on HealthKit alone, which was the only capability the watch did not inherit from the phone until now; publishing complications adds an App Group. Both are opt-in and neither implies the other, because granting one that is unused is refused by entitlement validation rather than ignored. parseSurfacesManifest no longer returns early when nothing reaches iOS. 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 case the watch families exist for. The build now says which of the two happened instead of reporting that watch kinds appear nowhere. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 171 ++++++++++-- .../builders/WatchNativeBuilder.java | 226 +++++++++++++++- .../WatchWidgetExtensionTargetTest.java | 243 ++++++++++++++++++ 3 files changed, 605 insertions(+), 35 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchWidgetExtensionTargetTest.java 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 4efc20b0cd8..16b44961878 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; @@ -3001,7 +3008,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 +4122,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; @@ -5036,6 +5047,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 +5376,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); @@ -6154,6 +6171,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"; @@ -6574,6 +6594,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 @@ -6587,20 +6618,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 " @@ -6614,6 +6666,64 @@ 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 + */ + private void writeWatchWidgetExtension(BuildRequest request, File distDir, File appSrcDir) + throws IOException { + if (!surfacesWatchEnabled) { + return; + } + IOSWidgetExtensionBuilder watchBuilder = new IOSWidgetExtensionBuilder() + .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) + .setDeploymentTarget(request.getArg("watchNative.surfaces.deploymentTarget", + IOSWidgetExtensionBuilder.WATCH_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 @@ -6630,11 +6740,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)) { @@ -6645,17 +6754,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; 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..91019aac65d 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; @@ -79,6 +85,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 +207,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 @@ -1368,6 +1409,17 @@ 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); + } 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 +1769,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 +1814,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 +1875,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 +1896,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 +2129,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 +2236,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 +3347,106 @@ 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()) { + 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/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..4d1fbedbe35 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchWidgetExtensionTargetTest.java @@ -0,0 +1,243 @@ +/* + * 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.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 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); + } +} From df9b845f29e3ab760879743f1f02d90311c68029 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:22:16 +0300 Subject: [PATCH 05/96] Make CI compile the complication extension on every PR The cn1ss sample already declares codename1.watchMain and a surfaces kind, so adding two watch families to that kind is enough to make the build-ios-watch job generate and compile CN1WatchWidgets for watchOS. That is the only automated check that the shared surfaces Swift stays portable to a platform with no UIGraphicsImageRenderer, no UIColor dynamic provider and no system widget families -- every one of which was a real break. Keeping small and medium on the same kind preserves the existing iOS coverage, and the manifest switches to the portable "families" spelling so that path is exercised too. The script then asserts the .appex is actually in the watch app's PlugIns folder, declares the WidgetKit extension point and carries an app group. The screenshot comparison cannot see any of that, and simctl cannot exercise a complication at all -- there is no API to place one on a watch face -- so the wiring needs checking directly or it is not checked. Co-Authored-By: Claude Opus 5 (1M context) --- .../common/src/main/resources/surfaces.json | 4 +-- scripts/run-watch-ui-tests.sh | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) 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" From b3103749623e7b90e295a89e84e19f19cfaa1fc7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:25:59 +0300 Subject: [PATCH 06/96] Deliver a complication tap to the action handler Tapping a complication launches the watch app with the widgetURL. There is no UIApplicationDelegate on watchOS, so the SwiftUI scene's onOpenURL is the only place that URL can be caught -- and nothing was catching it, so the tap opened the app and the action went nowhere. The cn1surface:// decode moves out of CodenameOne_GLAppDelegate.m, which is entirely #if !TARGET_OS_WATCH, into IOSNative.m, which compiles on both. The delegate now calls it rather than carrying its own copy, so the two platforms cannot drift on what a surface action means. Surfaces.dispatchAction already queues until the app registers its handler, which is what makes this work at all: a complication tap is almost always a cold start. The C entry point is declared in the generated watch bridging header, because a plain C function is invisible to Swift otherwise, and only when the app actually publishes complications -- an app without them keeps the scene and the header it had. Verified by compiling the surfaces native block for watchOS and iOS. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/CodenameOne_GLAppDelegate.m | 28 +++------- .../CodenameOne_GLViewController.h | 8 +++ Ports/iOSPort/nativeSources/IOSNative.m | 56 +++++++++++++++++++ .../builders/WatchNativeBuilder.java | 31 ++++++++-- .../WatchWidgetExtensionTargetTest.java | 45 +++++++++++++++ 5 files changed, 143 insertions(+), 25 deletions(-) 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 723719ffde3..cdd28332475 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -183,6 +183,14 @@ void cn1RunSyncOnMainQueue(void (^block)(void)); #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); +#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 c3a654f324a..c97636815d6 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -15339,6 +15339,62 @@ static BOOL cn1SurfacesMinOSSupported() { 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 + || [@"cn1surface" caseInsensitiveCompare:url.scheme] != NSOrderedSame) { + return NO; + } + 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 +// 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. +void cn1_watch_surface_url(const char *url) { + if (url == NULL) { + return; + } + POOL_BEGIN(); + NSString *str = [NSString stringWithUTF8String:url]; + if (str != nil) { + cn1HandleSurfaceURL([NSURL URLWithString:str]); + } + POOL_END(); +} +#endif + JAVA_OBJECT com_codename1_impl_ios_IOSNative_getSurfacesContainerPath__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { POOL_BEGIN(); NSString *path = cn1SurfacesContainerPath(); 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 91019aac65d..1f929214827 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 @@ -624,9 +624,23 @@ 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") .append(" func applicationDidBecomeActive() { CN1WatchHost.shared().applicationDidBecomeActive() }\n") @@ -780,8 +794,17 @@ 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"); + 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)); } /** 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 index 4d1fbedbe35..0a03c404550 100644 --- 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 @@ -224,6 +224,51 @@ void theWatchPlistAdvertisesItsOwnSurfacesFloor(@TempDir Path tmp) throws Except 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 watch app that publishes nothing must not carry either key. @Test void aWatchThatPublishesNothingCarriesNoSurfacesKeys(@TempDir Path tmp) throws Exception { From 4e57de0203e7549fcabd57b2ae4280fcb23baf52 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:31:01 +0300 Subject: [PATCH 07/96] Preview a complication in the simulator A developer designing a complication previously had nothing to look at. simctl cannot place one on a watch face, so short of building to a device and adding it by hand there was no way to see the layout at all -- while every phone family had a preview from the start. The Widgets window now lists the four watch families at the accessory families' own point sizes, and clips the round ones the way a face does. That clip is the point rather than decoration: a watch face shows nothing a circular complication draws into its corners, so previewing it square would make a design look fine that loses content on the device. layoutForSize gains the two substitutions the platform renderers already make, so the preview and the device agree on what gets shown. watchCorner borrows the circular layout -- a corner complication is round, and Wear OS has no corner slot at all -- and watchRectangular borrows lockscreen, which is the same WidgetKit family on Apple. Both are closer to what the developer designed than "default", which may well be a rectangular phone widget. What this previews is the node tree at the right size and shape, not the per-platform lowering: Wear OS reduces a complication to typed ComplicationData, so a layout that looks right here can still lose detail on a face. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/surfaces/SurfaceRasterizer.java | 20 ++++++- .../impl/javase/SimulatorWidgets.java | 48 ++++++++++++++-- .../surfaces/SurfaceRasterizerTest.java | 56 +++++++++++++++++++ 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceRasterizer.java b/CodenameOne/src/com/codename1/surfaces/SurfaceRasterizer.java index fab9e846308..61b10f0704d 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"); } 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/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")); From 67b90fae2d8305a043dea4dc48168d58551ee0b1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:42:10 +0300 Subject: [PATCH 08/96] Mirror a phone-published surface to the watch complication 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 Surfaces.publish() was invisible to a complication however well everything else was wired, and the developer's only recourse was to hand-roll the transfer. IOSSurfaceBridge now forwards the descriptor after the local write has already succeeded, so nothing here can leave the phone's own widget wrong. Which kinds are worth sending is decided at build time and written into the plist as CN1SurfacesWatchKinds, so publishing a phone-only kind costs one dictionary lookup. The delivery ladder is the interesting part. transferCurrentComplicationUserInfo is the only WCSession API that wakes the watch app in the background to refresh a complication, and it is budgeted at roughly fifty a day. Spending one when the user has placed no complication wastes what the app will want later, so both that case and an exhausted budget fall back to transferUserInfo -- queued, unbudgeted, and applied whenever the watch app next runs. That is materially weaker, which is why it is the fallback rather than the default. Over the 48KB property-list cap the imagery is shed first, on the grounds that a complication rendering its numbers with a missing glyph beats one that never updates; over the cap even then, it gives up and says so. Imagery travels in the same dictionary rather than through transferFile, which is a separate unordered queue with no atomicity against the descriptor -- a complication could render against art that had not landed, which is worse than a gap. Applying it on the watch is deliberately headless: a file write and a WidgetKit poke, touching no Java. The background wake exists to refresh a complication, and starting the whole application to do a file write would bring a UI forward nobody asked for. Reserved keys are routed before anything app-visible, the same way acknowledgement traffic already is, so the app never sees a message it did not send. publishRemote grows an images overload, which also fixes a latent gap: it discarded the side-map unconditionally, so a server-pushed descriptor referencing art has never rendered it. Verified by compiling the surfaces natives for watchOS and iOS, and by check-native-signatures against a rebuilt port -- which reports 0 fatal, the new byte[][] mangling included. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/com/codename1/surfaces/Surfaces.java | 29 ++- .../nativeSources/CN1WatchConnectivity.h | 9 + .../nativeSources/CN1WatchConnectivity.m | 103 +++++++++ .../CodenameOne_GLViewController.h | 9 + Ports/iOSPort/nativeSources/IOSNative.m | 200 ++++++++++++++++++ .../src/com/codename1/impl/ios/IOSNative.java | 22 ++ .../codename1/impl/ios/IOSSurfaceBridge.java | 40 ++++ .../com/codename1/builders/IPhoneBuilder.java | 24 ++- .../com/codename1/surfaces/SurfaceTest.java | 47 ++++ 9 files changed, 480 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/surfaces/Surfaces.java b/CodenameOne/src/com/codename1/surfaces/Surfaces.java index bc02026284a..3025880896f 100644 --- a/CodenameOne/src/com/codename1/surfaces/Surfaces.java +++ b/CodenameOne/src/com/codename1/surfaces/Surfaces.java @@ -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.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()); + b.publishWidgetTimeline(kindId, timelineJson, + images == null ? Collections.emptyMap() : images); } /// Asks the platform to re-render widgets from their already-published timelines. 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..f338b5fa496 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -936,6 +936,58 @@ - (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 || !s.isPaired || !s.isWatchAppInstalled) { + // No watch, or no watch app to receive it. Not a failure: most installs are this. + return; + } + // The ladder, weakest guarantee last. + // + // 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"); + } + @try { + if (wantsWake) { + [s transferCurrentComplicationUserInfo:info]; + } else { + [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); + } +#endif +} + // --- state --------------------------------------------------------------- - (BOOL)isSupported { @@ -1432,6 +1484,57 @@ - (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. +- (void)applyMirroredSurface:(NSDictionary *)info { + NSString *kind = [info objectForKey:@"cn1.surfaces.kind"]; + NSData *json = [info objectForKey:@"cn1.surfaces.json"]; + if (![kind isKindOfClass:[NSString class]] || ![json isKindOfClass:[NSData class]]) { + 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]; + } + } + } + cn1_watch_apply_mirrored_surface(kind, json, names, blobs); +} +#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/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index cdd28332475..b800153db7d 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -189,6 +189,15 @@ void cn1RunSyncOnMainQueue(void (^block)(void)); // 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. +void 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 diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index c97636815d6..4dfb77935ed 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -15504,6 +15504,204 @@ void com_codename1_impl_ios_IOSNative_surfacesEndActivity___java_lang_String_jav #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 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"]; + + // 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"]; + [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. +void cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, + NSArray *imageNames, NSArray *imageBlobs) { + NSString *container = cn1SurfacesContainerPath(); + if (container == nil || kind == nil || json == nil) { + return; + } + 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; + } + // 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; + } + [[imageBlobs objectAtIndex:i] + writeToFile:[kindDir stringByAppendingPathComponent: + [name stringByAppendingString:@".png"]] + atomically:YES]; + } + if (![json writeToFile:[kindDir stringByAppendingPathComponent:@"timeline.json"] + atomically:YES]) { + NSLog(@"[CN1Surfaces] could not write the mirrored timeline for \"%@\"", kind); + return; + } + Class bridge = cn1SurfacesBridgeClass(); + if (bridge != nil) { + ((void (*)(id, SEL, NSString *))objc_msgSend)((id)bridge, + NSSelectorFromString(@"reloadTimelines:"), kind); + } +} +#endif + JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesWidgetsSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { if (@available(iOS 14.0, *)) { POOL_BEGIN(); @@ -15557,6 +15755,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; } 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..c3fe2e7cbde 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java @@ -106,6 +106,46 @@ public void publishWidgetTimeline(String kindId, String timelineJson, return; } nativeInstance.surfacesReloadTimelines(kindId); + mirrorToWatch(kindId, timelineJson, images); + } + + /// 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) { 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 16b44961878..c2744dc6d6a 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 @@ -7312,14 +7312,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") + ""; } 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..4eb4d1c7b6f 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; @@ -851,6 +852,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"); From 84e137e0e21947416bb5503cfa193e0755b9e71a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:45:51 +0300 Subject: [PATCH 09/96] Recognize a Wear complication kind, and say what becomes of it The Android surfaces codegen never looked at families, so a kind declaring only watch complications quietly became a home-screen widget -- a surface the manifest never asked for. It now splits: a kind with a phone family still gets its AppWidgetProvider, and a watch-bearing kind is collected for the Wear services instead. iOS has always refused the same thing, so this is the two platforms agreeing rather than a new rule. That silence was the real problem, and the "companion Wear APK is not produced yet" log is replaced by diagnostics that name what actually happens: which kinds become complications, that watchCorner renders as circular because Wear OS has no corner slot, that watchRectangular earns a Tile as well, and -- when the build produces no Wear product at all -- that the declaration reaches no device and what to set to change that. watchModuleName answers "which module is the watch product" once, because everything downstream is the same code and differs only in the destination: "app" for a standalone build where the single APK is the watch app, "wear" for a companion build, null for a project that never asked for a watch. complicationTypes is the mapping WidgetSize already documents, made executable. It decides whether a complication can be placed in a given slot at all -- a watch face asks for one specific type and gets nothing if the source does not offer it. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 180 +++++++++++++++++- .../AndroidWatchSurfaceCodegenTest.java | 143 ++++++++++++++ 2 files changed, 314 insertions(+), 9 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidWatchSurfaceCodegenTest.java 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..1d63f1ddbb8 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,12 @@ 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(); /// 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 +347,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 @@ -3386,15 +3428,6 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { 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 @@ -3490,6 +3523,22 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { throw new BuildException("Invalid widget kind id '" + kindId + "' in surfaces.json; ids must match [a-z][a-z0-9_]*"); } + java.util.List kindFamilies = + com.codename1.util.SurfaceKindFamilies.read(surfaceKind); + 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_" + surfaceKindClassSuffix(kindId); String providerSource = "package com.codename1.impl.android;\n\n" + "/** Generated by the Codename One build from surfaces.json. */\n" @@ -3558,6 +3607,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { // runtime permission declared (permissionAdd dedups against user overrides) postNotificationsPermission = true; } + reportWatchSurfaces(request); } @@ -6876,6 +6926,118 @@ static String surfaceKindClassSuffix(String kindId) { return sb.toString(); } + /** + * 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."); + } + } + } + + /** 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 + */ + 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/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..e70e9281dc6 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidWatchSurfaceCodegenTest.java @@ -0,0 +1,143 @@ +/* + * 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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +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)); + } + + // --- 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")); + } + + // --- 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")); + } +} From 445086b7cd98de094344258d0aa361b0dd5cbcf3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:10:14 +0300 Subject: [PATCH 10/96] Render a Codename One surface on a Wear OS watch face Complications and Tiles now exist on Android, generated per watch-bearing kind, along with the companion Wear artifact that has never been produced. The split is deliberate: everything that does not touch androidx.wear lives in the port and is compiled by this repository, and only the two thin androidx-facing services ship as build-time resources. An app publishing no complication must not carry those libraries, but keeping the reader in the port is what lets CI catch a break in it -- and WearGlueCompilesTest compiles the injected services against the REAL CN1WatchSurface plus a stub tree, so a service that drifts from the reader's contract fails here rather than in a customer's Gradle build naming a file they never wrote. A complication is not a small widget, and the code says so. A watch face asks for one typed value and composes it into its own design, so the node tree is flattened and mined for content rather than rendered; padding, alignment and colour are the face's business. What is dropped is logged once per render, so a developer whose careful layout arrives as one number learns that is by design. A Tile really does render the tree, and two things come out better there than on a phone widget: circular progress renders natively where RemoteViews degrades to a linear bar, and per-node taps work where a small iOS widget honours only the root. The honest limitation is time -- a countdown is frozen and refreshed from the timeline, because ProtoLayout's dynamic expressions are version-sensitive and a frozen value that is always correct beats a ticking one that works on some watches. The companion module shares the app module's source, resource and asset dirs rather than copying them, which would roughly double disk and dex time on a cloud builder for a tree identical apart from one class. Both modules declare the same namespace -- required, not merely convenient, because the shared sources refer to R unqualified from the app's package. That AGP permits it was verified with a throwaway two-module project before this was written. The mirror lives in the port for a reason that is the opposite of the iOS one and points the same way: Executor.scanClassesForPermissions reads the app's own classes and not the core, so a core-level reference to com.codename1.wearable would fail to turn the Data Layer glue on and the mirror would silently do nothing. Reserved paths are routed before anything app-visible and without waking the app, matching how acknowledgement traffic is already handled. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/AndroidSurfaceBridge.java | 4 + .../android/surfaces/CN1SurfaceMirror.java | 258 ++++++++++ .../android/surfaces/CN1SurfaceRenderer.java | 61 +++ .../android/surfaces/CN1WatchSurface.java | 365 +++++++++++++++ .../surfaces/CN1WatchSurfaceNotifier.java | 106 +++++ .../builders/AndroidGradleBuilder.java | 443 ++++++++++++++++++ .../wear/CN1ComplicationDataSource.java | 275 +++++++++++ .../surfaces/wear/CN1SurfaceTileService.java | 372 +++++++++++++++ .../builders/wearable/CN1WearableBridge.java | 14 + .../wearable/CN1WearableListenerService.java | 37 ++ .../AndroidWatchSurfaceCodegenTest.java | 35 ++ .../builders/WearGlueCompilesTest.java | 185 ++++++++ .../resources/wear-surface-stubs/README.md | 13 + .../android/app/PendingIntent.javas | 7 + .../android/content/Context.javas | 6 + .../android/content/Intent.javas | 7 + .../android/content/res/Configuration.javas | 2 + .../android/content/res/Resources.javas | 7 + .../android/graphics/Bitmap.javas | 8 + .../android/graphics/drawable/Icon.javas | 3 + .../wear-surface-stubs/android/net/Uri.javas | 2 + .../wear-surface-stubs/android/os/Build.javas | 2 + .../android/util/DisplayMetrics.javas | 2 + .../wear-surface-stubs/android/util/Log.javas | 6 + .../futures/CallbackToFutureAdapter.javas | 7 + .../wear/protolayout/ActionBuilders.javas | 17 + .../wear/protolayout/ColorBuilders.javas | 5 + .../wear/protolayout/DimensionBuilders.javas | 9 + .../protolayout/LayoutElementBuilders.javas | 78 +++ .../wear/protolayout/ModifiersBuilders.javas | 40 ++ .../wear/protolayout/ResourceBuilders.javas | 26 + .../wear/protolayout/TimelineBuilders.javas | 15 + .../androidx/wear/tiles/RequestBuilders.javas | 5 + .../androidx/wear/tiles/TileBuilders.javas | 12 + .../androidx/wear/tiles/TileService.javas | 10 + .../complications/data/ComplicationData.javas | 2 + .../complications/data/ComplicationText.javas | 2 + .../complications/data/ComplicationType.javas | 8 + .../data/LongTextComplicationData.javas | 10 + .../data/MonochromaticImage.javas | 8 + .../MonochromaticImageComplicationData.javas | 9 + .../data/NoDataComplicationData.javas | 2 + .../data/PlainComplicationText.javas | 7 + .../data/RangedValueComplicationData.javas | 10 + .../data/ShortTextComplicationData.javas | 10 + .../ComplicationDataSourceService.javas | 9 + .../datasource/ComplicationRequest.javas | 3 + .../util/concurrent/ListenableFuture.javas | 2 + .../org/json/JSONArray.javas | 5 + .../org/json/JSONObject.javas | 15 + 50 files changed, 2546 insertions(+) create mode 100644 Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java create mode 100644 Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java create mode 100644 Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java create mode 100644 maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1ComplicationDataSource.java create mode 100644 maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1SurfaceTileService.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearGlueCompilesTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/README.md create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/app/PendingIntent.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/Context.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/Intent.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/res/Configuration.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/res/Resources.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/graphics/Bitmap.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/graphics/drawable/Icon.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/net/Uri.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/os/Build.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/util/DisplayMetrics.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/util/Log.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/concurrent/futures/CallbackToFutureAdapter.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ActionBuilders.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ColorBuilders.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/DimensionBuilders.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/LayoutElementBuilders.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ModifiersBuilders.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ResourceBuilders.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/TimelineBuilders.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/RequestBuilders.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileBuilders.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileService.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationData.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationText.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationType.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/LongTextComplicationData.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImage.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImageComplicationData.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/NoDataComplicationData.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/PlainComplicationText.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/RangedValueComplicationData.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ShortTextComplicationData.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataSourceService.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationRequest.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/com/google/common/util/concurrent/ListenableFuture.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONArray.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONObject.javas 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..63190b88063 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java @@ -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); + CN1SurfaceMirror.onPublished(ctx, kindId, timelineJson, images); } catch (Throwable t) { Log.w(TAG, "Failed to publish the timeline of widget kind " + kindId, t); } 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..044c5b28b70 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -0,0 +1,258 @@ +/* + * 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 the timeline 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. + sendImages(kindId, images); + 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); + } + } + + 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 + */ + public static void receive(Context ctx, String path, byte[] payload) { + try { + String kindId = kindOf(path); + if (kindId == null || payload == null) { + return; + } + WearableMessage message = WearableMessage.fromByteArray(path, payload); + byte[] json = message.getBytes("json", null); + if (json == null) { + return; + } + CN1SurfaceStore.kindDir(ctx, kindId).mkdirs(); + File out = new File(CN1SurfaceStore.kindDir(ctx, kindId), "timeline.json"); + writeAtomically(out, json); + CN1WatchSurfaceNotifier.requestUpdate(ctx, kindId); + } catch (Throwable t) { + Log.w(TAG, "Could not apply a mirrored surface from " + path, t); + } + } + + /** + * 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 + */ + public static void receiveFile(Context ctx, String path, byte[] payload) { + try { + String kindId = kindOf(path); + if (kindId == null || payload == null) { + return; + } + WearableMessage transfer = WearableMessage.fromByteArray(path, payload); + String name = transfer.getString("name", null); + byte[] contents = transfer.getBytes("contents", null); + if (name == null || contents == null) { + return; + } + 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; + } + File dir = CN1SurfaceStore.kindDir(ctx, kindId); + dir.mkdirs(); + writeAtomically(new File(dir, name), contents); + } catch (Throwable t) { + Log.w(TAG, "Could not store a mirrored image from " + path, t); + } + } + + /** 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; + } + + 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..18108496f49 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java @@ -738,6 +738,67 @@ 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); + 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"); 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..6e036c4e6f5 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java @@ -0,0 +1,365 @@ +/* + * 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; + + Reading(JSONObject layout, JSONObject state, long nextFlipDate) { + this.layout = layout; + this.state = state; + this.nextFlipDate = nextFlipDate; + } + + 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; + } + 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)); + } 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; + } + } + + /** + * 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; + } + JSONArray children = node.optJSONArray("c"); + 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) || "dyn".equals(type)) { + String text = CN1SurfaceRenderer.interpolate(node.optString("text", ""), state); + if (text != null && text.length() > 0) { + out.add(text); + } + } + } + return out; + } + + /** + * A progress node's value, clamped to 0..1. + * + *

Either literal or read from the entry's state by key, matching what the renderer does + * for a progress bar on every other platform.

+ * + * @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) { + if (prog == null) { + return -1f; + } + double value; + if (prog.has("value")) { + value = prog.optDouble("value", -1); + } else { + String key = prog.optString("valueKey", ""); + if (key.length() == 0 || state == null || !state.has(key)) { + return -1f; + } + value = state.optDouble(key, -1); + } + if (value < 0) { + return -1f; + } + return (float) Math.min(1.0, value); + } + + /** + * 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..0bd8118c676 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java @@ -0,0 +1,106 @@ +/* + * 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; + } + String suffix = classSuffix(kindId); + requestComplicationUpdate(ctx, "com.codename1.impl.android.CN1Complication_" + suffix); + requestTileUpdate(ctx, "com.codename1.impl.android.CN1Tile_" + suffix); + } + + 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); + } + } + + /** + * The class-name suffix the build derives from a kind id. + * + *

Delegates to the port's own copy rather than repeating it: this and + * {@code AndroidGradleBuilder.surfaceKindClassSuffix} name the same generated class from + * opposite sides of the build, and a mismatch is a silent miss rather than an error.

+ */ + private static String classSuffix(String kindId) { + return AndroidSurfaceBridge.toClassSuffix(kindId); + } +} 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 1d63f1ddbb8..b46475c8af5 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 @@ -319,6 +319,8 @@ public File getGradleProjectDirectory() { /// 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 generated phone stub's source, so the Wear module can derive its own from it. + private String generatedStubSource; /// 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. @@ -3391,6 +3393,17 @@ && 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. + // 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; + } if (usesWearable) { File wearImpl = new File(srcDir, "com/codename1/impl/android"); wearImpl.mkdirs(); @@ -3444,6 +3457,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { String intentsActivityMetaData = intentsShortcutsMetaData; String surfacesManifestEntries = ""; + String watchSurfacesManifestEntries = ""; if (usesSurfaces) { File surfacesJsonFile = new File(assetsDir, "surfaces.json"); if (!surfacesJsonFile.exists()) { @@ -3608,6 +3622,12 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { postNotificationsPermission = true; } reportWatchSurfaces(request); + watchSurfacesManifestEntries = generateWatchSurfaces(request, srcDir, resDir); + if (watchSurfacesManifestEntries.length() > 0) { + // Wear OS 3 is the floor for the complication data source and Tile APIs. Raised + // for the WATCH module; in a companion build the phone module keeps its own. + minSDK = maxInt("26", minSDK); + } } @@ -4830,6 +4850,11 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + carAppService + wearableListenerService + surfacesManifestEntries + // Only in a STANDALONE build does this manifest belong to the watch. A companion + // build's watch services go in the wear module's own manifest, which + // generateWearModule writes; putting them here too would declare a complication + // data source on a phone, where nothing can bind it. + + ("app".equals(watchModuleName(request)) ? watchSurfacesManifestEntries : "") + intentsManifestEntries + " \n" + " \n" @@ -5984,6 +6009,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"); @@ -6637,6 +6666,9 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { throw new BuildException("Failed to write gradle properties to "+gradleFile, ex); } + generateWearModule(request, studioProjectDir, gradleProps, watchSurfacesManifestEntries, + permissions + xPermissions, intVersion); + String rootGradleProps = "// Top-level build file where you can add configuration options common to all sub-projects/modules.\n" + "buildscript {\n" + " repositories {\n" + @@ -6969,6 +7001,417 @@ private void reportWatchSurfaces(BuildRequest request) { } } + /** + * 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 { + if (watchSurfaceKinds.isEmpty() || watchModuleName(request) == null) { + return ""; + } + File implDir = new File(srcDir, "com/codename1/impl/android"); + implDir.mkdirs(); + File surfacesDir = new File(srcDir, "com/codename1/impl/android/surfaces"); + surfacesDir.mkdirs(); + for (String resource : new String[] {"CN1ComplicationDataSource.java", + "CN1SurfaceTileService.java"}) { + 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]; + String label = xmlize(kind[1]); + String families = kind[2]; + String suffix = surfaceKindClassSuffix(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. + 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.

+ */ + 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"; + StringBuilder deps = new StringBuilder(); + deps.append(" ").append(compile) + .append(" 'androidx.wear.watchface:watchface-complications-data-source:") + .append(request.getArg("android.wear.complicationsVersion", "1.2.1")) + .append("'\n"); + boolean anyTile = false; + for (String[] kind : watchSurfaceKinds) { + if (declaresTile(kind[2])) { + anyTile = true; + break; + } + } + if (anyTile) { + deps.append(" ").append(compile).append(" 'androidx.wear.tiles:tiles:") + .append(request.getArg("android.wear.tilesVersion", "1.4.1")).append("'\n"); + deps.append(" ").append(compile) + .append(" 'androidx.wear.protolayout:protolayout:") + .append(request.getArg("android.wear.protoLayoutVersion", "1.2.1")) + .append("'\n"); + deps.append(" ").append(compile) + .append(" 'androidx.wear.protolayout:protolayout-material:") + .append(request.getArg("android.wear.protoLayoutVersion", "1.2.1")) + .append("'\n"); + deps.append(" ").append(compile) + .append(" 'androidx.concurrent:concurrent-futures:1.1.0'\n"); + } + request.putArgument("gradleDependencies", + request.getArg("gradleDependencies", "") + "\n" + 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 + * @param intVersion the phone's version code, which the watch's must exceed + */ + private void generateWearModule(BuildRequest request, File studioProjectDir, String appGradle, + String watchServices, String sharedPermissions, int intVersion) + 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") + .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 + ";"); + } + 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); + } + + int wearVersion = wearVersionCode(request, intVersion); + log("[wearable] Wear module version code " + wearVersion + " (phone " + intVersion + ")"); + + String wearGradle = appGradle + // Same namespace and applicationId; see the method comment. + .replace("versionCode " + intVersion, "versionCode " + wearVersion) + .replaceFirst("minSdkVersion \\d+", "minSdkVersion 26") + // Share the app module's tree instead of duplicating it, and add this module's + // own generated sources on top. + .replace("android {\n", + "android {\n" + + " sourceSets.main {\n" + + " java.srcDirs = ['../app/src/main/java', 'src/main/java']\n" + + " res.srcDirs = ['../app/src/main/res', 'src/main/res']\n" + + " assets.srcDirs = ['../app/src/main/assets']\n" + + " aidl.srcDirs = ['../app/src/main/aidl']\n" + + " manifest.srcFile 'src/main/AndroidManifest.xml'\n" + + " }\n") + // Libraries are shared from the app module rather than copied. + .replace("fileTree(dir: 'libs'", "fileTree(dir: '../app/libs'") + .replace("dirs 'libs'", "dirs '../app/libs'"); + 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); + } + + String wearManifest = "\n" + + "\n" + + " \n" + + sharedPermissions + + " \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" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + // A complication or Tile tap still needs the trampoline. + + " \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); + } + + // AndroidGradleBuilder does not run Gradle -- it generates the project and returns, and + // the build server packs the result. So the expected outputs and their roles have to be + // stated somewhere the server can read, or a second artifact it never heard of is simply + // dropped. + writeArtifactManifest(studioProjectDir, request); + log("[wearable] Generated the companion Wear OS module; the build produces a Wear " + + "artifact beside the phone one."); + } + + /** + * Records the artifacts this generated project produces and what each one is for. + * + *

The contract across the repository boundary: nothing here can prove the build server + * honours it, so until that half ships the companion form is verifiable through the + * android-source target and a local Gradle run.

+ */ + private void writeArtifactManifest(File studioProjectDir, BuildRequest request) + throws BuildException { + StringBuilder props = new StringBuilder(); + props.append("# Generated by the Codename One Android builder.\\n"); + props.append("# Which artifacts this project produces, and the role of each. A role\\n"); + props.append("# suffix is appended to the returned file name so two artifacts of the\\n"); + props.append("# same kind cannot collapse onto one path.\\n"); + props.append("artifact.0.module=app\\n"); + props.append("artifact.0.role=primary\\n"); + props.append("artifact.0.suffix=\\n"); + props.append("artifact.1.module=wear\\n"); + props.append("artifact.1.role=wear\\n"); + props.append("artifact.1.suffix=-wear\\n"); + try { + createFile(new File(studioProjectDir, "cn1-artifacts.properties"), + props.toString().getBytes(StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new BuildException("Failed to write the artifact manifest", ex); + } + } + + /** + * 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) { + String explicit = request.getArg("android.watchVersionCode", ""); + if (explicit.length() > 0) { + return parseIntSafe(explicit, intVersion + 1); + } + return intVersion + parseIntSafe( + request.getArg("android.watchVersionCodeOffset", "1"), 1); + } + + private static int parseIntSafe(String value, int fallback) { + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException ex) { + return fallback; + } + } + /** Joins declared families for the codegen tables, normalized to the portable spelling. */ static String joinFamilies(List families) { StringBuilder sb = new StringBuilder(); 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..e5f513f7d07 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1ComplicationDataSource.java @@ -0,0 +1,275 @@ +/* + * 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.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.datasource.ComplicationDataSourceService; +import androidx.wear.watchface.complications.datasource.ComplicationRequest; + +import org.json.JSONObject; + +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) { + ComplicationData data = null; + try { + data = build(request.getComplicationType()); + } catch (Throwable t) { + Log.w(TAG, "Could not build complication data for kind " + getKindId(), t); + } + try { + listener.onComplicationData(data == null ? noData() : data); + } catch (Throwable t) { + Log.w(TAG, "Could not deliver complication data for kind " + getKindId(), t); + } + } + + @Override + public ComplicationData getPreviewData(ComplicationType type) { + try { + ComplicationData data = build(type); + 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. + return shortText(getKindId(), 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.

+ */ + 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 reading = 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); + + if (ComplicationType.LONG_TEXT.equals(type)) { + String title = texts.isEmpty() ? getKindId() : texts.get(0); + String body = texts.size() > 1 ? join(texts, 1) : ""; + return new LongTextComplicationData.Builder(plain(body.length() == 0 ? title : body), + plain(title)) + .setTitle(body.length() == 0 ? null : plain(title)) + .setTapAction(tap) + .build(); + } + if (ComplicationType.RANGED_VALUE.equals(type)) { + JSONObject prog = CN1WatchSurface.firstOfType(nodes, "prog"); + float value = CN1WatchSurface.progressValue(prog, reading.getState()); + 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; + } + RangedValueComplicationData.Builder builder = + new RangedValueComplicationData.Builder(value, 0f, 1f, + plain(texts.isEmpty() ? getKindId() : texts.get(0))); + if (!texts.isEmpty()) { + builder.setText(plain(shorten(texts.get(0)))); + } + return builder.setTapAction(tap).build(); + } + if (ComplicationType.MONOCHROMATIC_IMAGE.equals(type)) { + Icon icon = monochromeIcon(nodes, reading.getState()); + if (icon == null) { + return null; + } + return new MonochromaticImageComplicationData.Builder( + new MonochromaticImage.Builder(icon).build(), + plain(texts.isEmpty() ? getKindId() : texts.get(0))) + .setTapAction(tap) + .build(); + } + if (ComplicationType.SHORT_TEXT.equals(type)) { + if (texts.isEmpty()) { + return null; + } + return shortText(shorten(texts.get(0)), texts.size() > 1 ? shorten(texts.get(1)) : null, + tap); + } + return null; + } + + private ShortTextComplicationData shortText(String text, String title, PendingIntent tap) { + // The untruncated string becomes the content description, so a screen reader still hears + // what the layout said even where the slot shows seven characters. + ShortTextComplicationData.Builder builder = + new ShortTextComplicationData.Builder(plain(shorten(text)), plain(text)); + if (title != null && title.length() > 0) { + builder.setTitle(plain(title)); + } + return builder.setTapAction(tap).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); + } + + 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, intent.getDataString().hashCode(), 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 (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."); + } + } + + private static String shorten(String text) { + if (text == null) { + return ""; + } + return text.length() <= SHORT_TEXT_MAX ? text : text.substring(0, SHORT_TEXT_MAX); + } + + 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..9c56c90310e --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1SurfaceTileService.java @@ -0,0 +1,372 @@ +/* + * 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.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; + + /** 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()); + 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); + freshness = freshnessFor(reading.getNextFlipDate()); + version = String.valueOf(imageNames(reading.getLayout()).hashCode()); + } + } 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.

+ */ + private static long freshnessFor(long nextFlipDate) { + if (nextFlipDate <= 0) { + return 0; + } + long delta = nextFlipDate - System.currentTimeMillis(); + if (delta < MIN_FRESHNESS_MILLIS) { + return MIN_FRESHNESS_MILLIS; + } + return Math.min(delta, MAX_FRESHNESS_MILLIS); + } + + private ResourceBuilders.Resources buildResources() { + ResourceBuilders.Resources.Builder builder = new ResourceBuilders.Resources.Builder(); + String version = "0"; + try { + CN1WatchSurface.Reading reading = + CN1WatchSurface.read(this, getKindId(), "watchRectangular"); + if (reading != null) { + version = String.valueOf(imageNames(reading.getLayout()).hashCode()); + for (Map.Entry e + : imageNodes(reading.getLayout()).entrySet()) { + Bitmap bitmap = CN1WatchSurface.bitmap(this, getKindId(), e.getValue(), + reading.getState()); + if (bitmap == null) { + continue; + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); + 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()); + } + } + } 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) { + if (node == null || depth > 8) { + return text(""); + } + String type = node.optString("t", ""); + if ("col".equals(type)) { + LayoutElementBuilders.Column.Builder col = new LayoutElementBuilders.Column.Builder(); + for (JSONObject child : children(node)) { + col.addContent(render(child, state, depth + 1)); + } + return col.setModifiers(modifiers(node)).build(); + } + if ("row".equals(type)) { + LayoutElementBuilders.Row.Builder row = new LayoutElementBuilders.Row.Builder(); + for (JSONObject child : children(node)) { + row.addContent(render(child, state, depth + 1)); + } + return row.setModifiers(modifiers(node)).build(); + } + if ("box".equals(type)) { + LayoutElementBuilders.Box.Builder box = new LayoutElementBuilders.Box.Builder(); + for (JSONObject child : children(node)) { + box.addContent(render(child, state, depth + 1)); + } + return box.setModifiers(modifiers(node)).build(); + } + if ("spacer".equals(type)) { + return new LayoutElementBuilders.Spacer.Builder() + .setWidth(DimensionBuilders.dp(Math.max(1, node.optInt("w", 4)))) + .setHeight(DimensionBuilders.dp(Math.max(1, node.optInt("h", 4)))) + .build(); + } + if ("img".equals(type) || "vec".equals(type)) { + String name = imageId(node); + return new LayoutElementBuilders.Image.Builder() + .setResourceId(name) + .setWidth(DimensionBuilders.dp(Math.max(1, node.optInt("w", 24)))) + .setHeight(DimensionBuilders.dp(Math.max(1, node.optInt("h", 24)))) + .setModifiers(modifiers(node)) + .build(); + } + if ("prog".equals(type)) { + // A ProtoLayout arc renders the ring natively -- the one place a Tile beats the phone + // widget, which has to degrade a circular bar to a linear one. + float value = CN1WatchSurface.progressValue(node, state); + return new LayoutElementBuilders.Arc.Builder() + .addContent(new LayoutElementBuilders.ArcLine.Builder() + .setLength(DimensionBuilders.degrees( + 360f * (value < 0 ? 0f : value))) + .setThickness(DimensionBuilders.dp(6)) + .build()) + .build(); + } + // text, dyn and anything unknown: whatever string the node resolves to. A dyn value is + // frozen here; see the class comment. + return styledText(node, state); + } + + private LayoutElementBuilders.LayoutElement styledText(JSONObject node, JSONObject state) { + String value = 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 && color.has("d")) { + font.setColor(ColorBuilders.argb(color.optInt("d"))); + } + 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(); + JSONObject pad = node.optJSONObject("pad"); + if (pad != null) { + mods.setPadding(new ModifiersBuilders.Padding.Builder() + .setStart(DimensionBuilders.dp(pad.optInt("l", 0))) + .setEnd(DimensionBuilders.dp(pad.optInt("r", 0))) + .setTop(DimensionBuilders.dp(pad.optInt("t", 0))) + .setBottom(DimensionBuilders.dp(pad.optInt("b", 0))) + .build()); + } + JSONObject bg = node.optJSONObject("bg"); + if (bg != null && bg.has("d")) { + ModifiersBuilders.Background.Builder background = + new ModifiersBuilders.Background.Builder() + .setColor(ColorBuilders.argb(bg.optInt("d"))); + 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) { + mods.setClickable(new ModifiersBuilders.Clickable.Builder() + .setId(action.optString("id")) + .setOnClick(new androidx.wear.protolayout.ActionBuilders.LaunchAction.Builder() + .setAndroidActivity( + new androidx.wear.protolayout.ActionBuilders.AndroidActivity + .Builder() + .setPackageName(getPackageName()) + .setClassName(CN1SurfaceActionActivity.class.getName()) + .build()) + .build()) + .build()); + } + return mods.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(); + JSONArray array = node.optJSONArray("c"); + 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. + * + *

Published names are content hashes, which is what lets the resources version be a + * hash of the name list: unchanged art is never re-sent.

+ */ + private static String imageId(JSONObject node) { + String name = node.optString("name", ""); + return name.length() > 0 ? name : (ROOT_ID + "_" + System.identityHashCode(node)); + } + + 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..685c0dc0d67 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"; /** 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..898fd456275 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 @@ -303,6 +303,22 @@ public void run() { }); } + /** + * 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 +390,18 @@ && 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 && !deleted + && com.codename1.impl.android.surfaces.CN1SurfaceMirror.isMirrorPath(appPath)) { + com.codename1.impl.android.surfaces.CN1SurfaceMirror.receive(this, appPath, + readMirrorPayload(event)); + 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 +470,15 @@ && 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 (com.codename1.impl.android.surfaces.CN1SurfaceMirror + .isMirrorPath(transfer.logicalPath)) { + // Mirrored complication artwork. Stored beside the descriptor that names + // it, without waking the app: see the data-item branch above. + com.codename1.impl.android.surfaces.CN1SurfaceMirror.receiveFile(this, + transfer.logicalPath, transfer.payload); + CN1WearableBridge.confirmTransferDelivered(this, uri, transferSeq, true); + 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 index e70e9281dc6..1c11841ff36 100644 --- 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 @@ -129,6 +129,41 @@ 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() { + assertEquals(101, AndroidGradleBuilder.wearVersionCode(request(), 100)); + } + + @Test + void theWearVersionCodeCanBeSetOutright() { + BuildRequest req = request(); + req.putArgument("android.watchVersionCode", "5000"); + + assertEquals(5000, AndroidGradleBuilder.wearVersionCode(req, 100)); + } + + @Test + void theOffsetCanBeWidenedForAProjectThatNumbersItsBuildsTightly() { + 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() { + BuildRequest req = request(); + req.putArgument("android.watchVersionCodeOffset", "not a number"); + + assertEquals(101, AndroidGradleBuilder.wearVersionCode(req, 100)); + } + // --- tiles ------------------------------------------------------------------ /// Only the rectangular family is roomy enough for a layout rather than a readout, so it is 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..832e2efcee0 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearGlueCompilesTest.java @@ -0,0 +1,185 @@ +/* + * 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 two 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")); + 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" + + "}\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" + + "}\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/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..584b1781cfe --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ActionBuilders.javas @@ -0,0 +1,17 @@ +package androidx.wear.protolayout; +public final class ActionBuilders { + public interface Action { } + public static class AndroidActivity { + public static class Builder { + public Builder setPackageName(String p) { return this; } + public Builder setClassName(String c) { 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..817e1f0411d --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/DimensionBuilders.javas @@ -0,0 +1,9 @@ +package androidx.wear.protolayout; +public final class DimensionBuilders { + public static class DpProp { } + public static class SpProp { } + public static class DegreesProp { } + public static DpProp dp(float v) { 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..44bb5025deb --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/LayoutElementBuilders.javas @@ -0,0 +1,78 @@ +package androidx.wear.protolayout; +public final class LayoutElementBuilders { + 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 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.DpProp d) { return this; } + public Builder setHeight(DimensionBuilders.DpProp d) { return this; } + public Spacer build() { return null; } + } + } + public static class Image implements LayoutElement { + public static class Builder { + 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 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..bd13972bd03 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/RequestBuilders.javas @@ -0,0 +1,5 @@ +package androidx.wear.tiles; +public final class RequestBuilders { + public static class TileRequest { } + public static class ResourcesRequest { } +} 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/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..e237707ee9e --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/LongTextComplicationData.javas @@ -0,0 +1,10 @@ +package androidx.wear.watchface.complications.data; +import android.app.PendingIntent; +public class LongTextComplicationData extends ComplicationData { + public static class Builder { + 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..1f63483dbfa --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImageComplicationData.javas @@ -0,0 +1,9 @@ +package androidx.wear.watchface.complications.data; +import android.app.PendingIntent; +public class MonochromaticImageComplicationData extends ComplicationData { + public static class Builder { + 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..94ac84d9848 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/RangedValueComplicationData.javas @@ -0,0 +1,10 @@ +package androidx.wear.watchface.complications.data; +import android.app.PendingIntent; +public class RangedValueComplicationData extends ComplicationData { + public static class Builder { + 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..87425617e93 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ShortTextComplicationData.javas @@ -0,0 +1,10 @@ +package androidx.wear.watchface.complications.data; +import android.app.PendingIntent; +public class ShortTextComplicationData extends ComplicationData { + public static class Builder { + 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/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..05885c2c234 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataSourceService.javas @@ -0,0 +1,9 @@ +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); } + 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/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/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..5866cdaaab8 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONArray.javas @@ -0,0 +1,5 @@ +package org.json; +public class JSONArray { + public int length() { return 0; } + public JSONObject optJSONObject(int i) { return null; } +} 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..e70a9294cd1 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONObject.javas @@ -0,0 +1,15 @@ +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 double optDouble(String k, double d) { return d; } + public JSONObject optJSONObject(String k) { return null; } + public JSONArray optJSONArray(String k) { return null; } + public String toString() { return ""; } +} From 822e641e8c382584e3e6f7f22b58237303f372de Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:18:00 +0300 Subject: [PATCH 11/96] Document what the watch actually shows Five statements in the wearables chapter said the opposite of the truth -- that Wear OS has no companion form, that no complication target is generated on either platform, that a companion Android build hands back one artifact. They are deleted rather than reworded, and the summary table with them. What replaces them is mostly a warning, because the surprising part of this feature is not that it works but how much a watch face discards. A complication is not a small widget: the face asks for one typed value and composes it into its own design, so the node tree is mined for content rather than rendered, and on Wear OS a kind supplies at most two text nodes and one image. That has a section of its own, with the per-node mapping in the surfaces chapter, because someone reading only the "declare a family" paragraph would design something the face will not show. Two places the Tile beats the phone widget are written down too -- native circular progress and per-node taps -- along with the one place it loses, a frozen countdown, and why a frozen value that is always right beats a ticking one that works on some watches. The mirror gets its own section, leading with the fact that makes it necessary: a watch app has its own storage, so a phone-side publish reaches a complication only because the framework carries it. Its budgets, caps and degradation are stated rather than left to be discovered, and so is the cost -- declaring a watch family on Android puts play-services-wearable in the phone APK. Both blog posts still documented the retired android.wear hint; they now say what drives the build, with a note that the old hint keeps working. Vale, LanguageTool, the paragraph capitalization check and asciidoctor all report zero across the whole guide. Co-Authored-By: Claude Opus 5 (1M context) --- .../External-Surfaces.asciidoc | 67 +++++++- docs/developer-guide/Wearables.asciidoc | 150 +++++++++++++++--- .../blog/native-apple-watch-and-wear.md | 8 +- ...ple-watch-game-builder-crash-protection.md | 2 +- 4 files changed, 192 insertions(+), 35 deletions(-) diff --git a/docs/developer-guide/External-Surfaces.asciidoc b/docs/developer-guide/External-Surfaces.asciidoc index a302964ab6f..d9abc898a06 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` | `+1` | The Wear artifact's version code, which must outrank the phone's for Play to pick it on a watch | `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..ca4c2cc64e8 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,22 @@ 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 one, which suits the usual +numbering; `android.watchVersionCodeOffset` widens the gap and +`android.watchVersionCode` sets it 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 +496,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 +556,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 +566,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 From ad8aed67216bc28a87c3f2423f99afebaba50113 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 07:43:32 +0300 Subject: [PATCH 12/96] Read the wire format the serializer actually writes Five review findings, four of which were the same shape: code that compiles, never throws, and renders nothing. **Containers were read under the wrong key.** `SurfaceContainer` serializes its children as `ch`; both the complication reader and the Tile renderer looked for `c`. Every row, column and box therefore looked empty, so a complication mined a layout with no text, no progress and no imagery in it and a Tile rendered nothing. That is indistinguishable from an app that published nothing, which is why it survived a green build. **Dynamic nodes carry no text to interpolate.** A `dyn` node serializes a style plus a date or a dateKey, so asking it for `text` resolved to an empty string and every countdown, clock and relative date vanished. They now go through the core's own formatter -- made public rather than copied -- so a countdown reads the same on a watch face as in the simulator preview and on a home screen. Both are now pinned by SurfaceWatchWireFormatTest, which asserts the field names against the serializer itself. The readers live in the Android port and cannot be unit tested from there, but the wire format can be, and that is what makes this kind of drift visible instead of silent. **A Tile tap dropped its action.** A Clickable's id is ProtoLayout interaction metadata and never reaches the started activity, so the trampoline -- which dispatches only when EXTRA_ACTION_ID is present -- opened the app and discarded the action id, source and parameters. The extras are attached explicitly now, the same three a widget tap sends. **A companion build raised the phone's minSdk.** Declaring a watch family pushed the shared floor to 26 before the phone module's Gradle file was written, so a phone APK that had supported API 21-25 became uninstallable on the devices it already served. The floor now rises only for a standalone build, where the app module IS the watch product; the wear module sets its own. **Mirrored artwork never triggered a redraw.** A file transfer is asynchronous and unordered against the descriptor, so art routinely lands after the timeline that references it -- and only the descriptor asked for a refresh. The first render showed a gap and nothing asked again until the next publish. Also fixes the two SpotBugs findings that failed CI: mkdirs() return values were ignored. The naive check is wrong here, since mkdirs() answers false both when the directory could not be created and when it already exists -- which is the common case -- so existence afterwards is what the callers test. SpotBugs is now zero across android, ios, codenameone-maven-plugin and core-unittests; 917 plugin and 5202 core tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/surfaces/SurfaceRasterizer.java | 14 +- .../android/surfaces/CN1SurfaceMirror.java | 28 +++- .../android/surfaces/CN1SurfaceRenderer.java | 54 ++++++++ .../android/surfaces/CN1WatchSurface.java | 50 ++++++- .../builders/AndroidGradleBuilder.java | 46 ++----- .../wear/CN1ComplicationDataSource.java | 21 +++ .../surfaces/wear/CN1SurfaceTileService.java | 55 ++++++-- .../builders/WearGlueCompilesTest.java | 4 + .../wear/protolayout/ActionBuilders.javas | 8 ++ .../surfaces/SurfaceWatchWireFormatTest.java | 122 ++++++++++++++++++ 10 files changed, 348 insertions(+), 54 deletions(-) create mode 100644 maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceWatchWireFormatTest.java diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceRasterizer.java b/CodenameOne/src/com/codename1/surfaces/SurfaceRasterizer.java index 61b10f0704d..b4a1f2544fb 100644 --- a/CodenameOne/src/com/codename1/surfaces/SurfaceRasterizer.java +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceRasterizer.java @@ -298,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/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index 044c5b28b70..0879d54e973 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -179,9 +179,9 @@ public static void receive(Context ctx, String path, byte[] payload) { if (json == null) { return; } - CN1SurfaceStore.kindDir(ctx, kindId).mkdirs(); - File out = new File(CN1SurfaceStore.kindDir(ctx, kindId), "timeline.json"); - writeAtomically(out, json); + File kindDir = CN1SurfaceStore.kindDir(ctx, kindId); + mkdirs(kindDir); + writeAtomically(new File(kindDir, "timeline.json"), json); CN1WatchSurfaceNotifier.requestUpdate(ctx, kindId); } catch (Throwable t) { Log.w(TAG, "Could not apply a mirrored surface from " + path, t); @@ -218,8 +218,14 @@ public static void receiveFile(Context ctx, String path, byte[] payload) { return; } File dir = CN1SurfaceStore.kindDir(ctx, kindId); - dir.mkdirs(); + 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); } catch (Throwable t) { Log.w(TAG, "Could not store a mirrored image from " + path, t); } @@ -238,6 +244,20 @@ private static String kindOf(String path) { 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); 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 18108496f49..fa5aff4035d 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java @@ -869,6 +869,60 @@ private static int resolveColor(JSONObject color, RenderContext rc, int fallback return rc.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) { String dateKey = node.optString("dateKey", null); if (dateKey != null && dateKey.length() > 0 && rc.state != null) { diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java index 6e036c4e6f5..f7c9c0a170c 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java @@ -217,7 +217,10 @@ private static void flattenInto(JSONObject node, List out, int depth if (node == null || depth > MAX_DEPTH) { return; } - JSONArray children = node.optJSONArray("c"); + // "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); @@ -247,16 +250,59 @@ 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) || "dyn".equals(type)) { + 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. * 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 b46475c8af5..03e5f0a1255 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 @@ -3623,9 +3623,14 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } reportWatchSurfaces(request); watchSurfacesManifestEntries = generateWatchSurfaces(request, srcDir, resDir); - if (watchSurfacesManifestEntries.length() > 0) { - // Wear OS 3 is the floor for the complication data source and Tile APIs. Raised - // for the WATCH module; in a companion build the phone module keeps its own. + if (watchSurfacesManifestEntries.length() > 0 && "app".equals(watchModuleName(request))) { + // 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); } } @@ -7347,42 +7352,13 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str throw new BuildException("Failed to add the Wear module to settings.gradle", ex); } - // AndroidGradleBuilder does not run Gradle -- it generates the project and returns, and - // the build server packs the result. So the expected outputs and their roles have to be - // stated somewhere the server can read, or a second artifact it never heard of is simply - // dropped. - writeArtifactManifest(studioProjectDir, request); + // 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."); } - /** - * Records the artifacts this generated project produces and what each one is for. - * - *

The contract across the repository boundary: nothing here can prove the build server - * honours it, so until that half ships the companion form is verifiable through the - * android-source target and a local Gradle run.

- */ - private void writeArtifactManifest(File studioProjectDir, BuildRequest request) - throws BuildException { - StringBuilder props = new StringBuilder(); - props.append("# Generated by the Codename One Android builder.\\n"); - props.append("# Which artifacts this project produces, and the role of each. A role\\n"); - props.append("# suffix is appended to the returned file name so two artifacts of the\\n"); - props.append("# same kind cannot collapse onto one path.\\n"); - props.append("artifact.0.module=app\\n"); - props.append("artifact.0.role=primary\\n"); - props.append("artifact.0.suffix=\\n"); - props.append("artifact.1.module=wear\\n"); - props.append("artifact.1.role=wear\\n"); - props.append("artifact.1.suffix=-wear\\n"); - try { - createFile(new File(studioProjectDir, "cn1-artifacts.properties"), - props.toString().getBytes(StandardCharsets.UTF_8)); - } catch (IOException ex) { - throw new BuildException("Failed to write the artifact manifest", ex); - } - } /** * The Wear artifact's version code, which must outrank the phone's. 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 index e5f513f7d07..0cae09869a0 100644 --- 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 @@ -238,6 +238,11 @@ private void reportDroppedContent(List nodes, List texts) { 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() @@ -254,6 +259,22 @@ private static String shorten(String text) { return text.length() <= SHORT_TEXT_MAX ? text : text.substring(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++) { 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 index 9c56c90310e..9c1d5168f2e 100644 --- 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 @@ -259,7 +259,12 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s } private LayoutElementBuilders.LayoutElement styledText(JSONObject node, JSONObject state) { - String value = CN1SurfaceRenderer.interpolate(node.optString("text", ""), 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) { @@ -313,26 +318,56 @@ private ModifiersBuilders.Modifiers modifiers(JSONObject node) { if (action != null && action.optString("id", "").length() > 0) { mods.setClickable(new ModifiersBuilders.Clickable.Builder() .setId(action.optString("id")) - .setOnClick(new androidx.wear.protolayout.ActionBuilders.LaunchAction.Builder() - .setAndroidActivity( - new androidx.wear.protolayout.ActionBuilders.AndroidActivity - .Builder() - .setPackageName(getPackageName()) - .setClassName(CN1SurfaceActionActivity.class.getName()) - .build()) - .build()) + .setOnClick(launchAction(action)) .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) { + androidx.wear.protolayout.ActionBuilders.AndroidActivity.Builder activity = + new androidx.wear.protolayout.ActionBuilders.AndroidActivity.Builder() + .setPackageName(getPackageName()) + .setClassName(CN1SurfaceActionActivity.class.getName()); + 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(); - JSONArray array = node.optJSONArray("c"); + // "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); 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 index 832e2efcee0..63c103ee693 100644 --- 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 @@ -120,6 +120,10 @@ void theInjectedWearServicesCompile(@TempDir Path tmp) throws IOException { + "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" + "}\n").getBytes("UTF-8")); Files.write(shims.resolve("CN1SurfaceActionActivity.java"), ("package com.codename1.impl.android.surfaces;\n" 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 index 584b1781cfe..51d03dd70e1 100644 --- 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 @@ -1,10 +1,18 @@ 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; } } } 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..1071a5469c9 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceWatchWireFormatTest.java @@ -0,0 +1,122 @@ +/* + * 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")); + } + + /// 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)); + } +} From badd9cd1fa1bd8d94de21f130bf7c6b7c32984f7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:02:00 +0300 Subject: [PATCH 13/96] Give the watch slice its natives, its listener and its padding Four more findings from the second review round, three of which meant a generated artifact could not work at all. **The watch slice lost the surfaces define.** CN1_USE_WIDGETS was still flipped only for surfacesExtensionEnabled, so a manifest declaring nothing but complications compiled the watch slice without it -- and since the WatchConnectivity delegate calls cn1_watch_apply_mirrored_surface, which that define guards, the watch slice failed to LINK rather than merely doing nothing. That edit was written once before and lost: a patch script asserted on a later anchor and never wrote the file. The same failure ate the daemon's appendWidgetExtension call site. Both are now verified present rather than assumed. **The Wear module declared no Data Layer listener.** Its manifest is selected outright by the module's sourceSets rather than merged with the phone's, so nothing the phone declares reaches it -- and the watch needs this one more than the phone does, being the half that RECEIVES a mirrored complication. Play services had nothing to bind in the watch APK, so every mirrored descriptor was dropped and complications stayed at whatever the watch had published for itself. **Tile padding was read as an object.** SurfaceNode serializes it as the array [top, right, bottom, left], which is what the RemoteViews renderer reads, so asking for an object returned null for every valid descriptor and all declared padding was silently discarded. **A Tile's vector resources were keyed by object identity.** The layout request and the resources request are separate calls that each re-read and re-parse the timeline, so the two ids never matched: the layout referenced a resource the returned map did not contain and every vector rendered as a missing image. The id now comes from the node's serialized content, which is equal across parses -- and two identical vectors sharing one resource is correct, since they draw the same thing. SurfaceWatchWireFormatTest grows the padding and vector cases, so the class of bug that produced three of these four -- reading a field the serializer does not write -- is pinned against the serializer rather than found by review. SpotBugs zero across android, ios, codenameone-maven-plugin and core-unittests; 917 plugin and 5204 core tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 14 ++++++-- .../com/codename1/builders/IPhoneBuilder.java | 7 +++- .../surfaces/wear/CN1SurfaceTileService.java | 33 +++++++++++++------ .../org/json/JSONArray.javas | 1 + .../surfaces/SurfaceWatchWireFormatTest.java | 31 +++++++++++++++++ 5 files changed, 72 insertions(+), 14 deletions(-) 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 03e5f0a1255..dd7f3f4196a 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 @@ -6672,7 +6672,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } generateWearModule(request, studioProjectDir, gradleProps, watchSurfacesManifestEntries, - permissions + xPermissions, intVersion); + permissions + xPermissions, intVersion, wearableListenerService); String rootGradleProps = "// Top-level build file where you can add configuration options common to all sub-projects/modules.\n" + "buildscript {\n" + @@ -7226,10 +7226,12 @@ private void addWatchSurfaceDependencies(BuildRequest request) throws BuildExcep * @param watchServices the complication and Tile manifest entries * @param sharedPermissions the permissions the phone manifest declares * @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) - throws BuildException { + String watchServices, String sharedPermissions, int intVersion, + String wearableListenerService) throws BuildException { if (!"wear".equals(watchModuleName(request))) { return; } @@ -7321,6 +7323,12 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str + " \n" + " \n" + " \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 // A complication or Tile tap still needs the trampoline. + " children(JSONObject node) { } /** - * The resource id an image node maps to. + * The resource id an image node maps to, derived from its CONTENT. * - *

Published names are content hashes, which is what lets the resources version be a - * hash of the name list: unchanged art is never re-sent.

+ *

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", ""); - return name.length() > 0 ? name : (ROOT_ID + "_" + System.identityHashCode(node)); + if (name.length() > 0) { + return name; + } + return ROOT_ID + "_vec" + Integer.toHexString(node.toString().hashCode()); } private static Map imageNodes(JSONObject root) { 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 index 5866cdaaab8..46f14161c84 100644 --- 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 @@ -2,4 +2,5 @@ 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/core-unittests/src/test/java/com/codename1/surfaces/SurfaceWatchWireFormatTest.java b/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceWatchWireFormatTest.java index 1071a5469c9..9d50de859f5 100644 --- a/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceWatchWireFormatTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceWatchWireFormatTest.java @@ -82,6 +82,37 @@ void everyContainerKindUsesTheSameChildrenKey() throws Exception { 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 From e4952d5c96be8f63da5b3fe51ef7c1613b3858f7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:18:18 +0300 Subject: [PATCH 14/96] Keep the wear libraries, and their SDK floor, on the watch module The Android CI failure and four more review findings, all of them the same mistake in different places: the phone and the watch are separate products in a companion build, and things scoped to one kept reaching the other. **The androidx.wear libraries reached the phone module.** They went into the shared gradleDependencies hint, which in a companion build feeds both modules -- and they declare minSdk 26 while the phone keeps its own floor. A phone app on API 24 therefore stopped building the moment a watch family was declared, failing its manifest merge against libraries it never uses. They now go into the wear module's own dependency block, and only a standalone build -- where that single module IS the watch -- puts them in the shared one. **The Data Layer glue was decided before the kinds were parsed**, so watchSurfaceKinds was always empty at that point. An app that publishes complications and never writes a line of com.codename1.wearable got no glue, no dependency and an empty listener declaration -- the mirror had no transport at either end. The block moves after the surfaces parse. **A watch-only manifest could not mirror at all.** The phone was deliberately left without the App Group entitlement, so its container did not resolve, areWidgetsSupported() answered false, and Surfaces.publish() returned before the bridge -- taking the mirror with it. The one manifest this feature exists for was the one that could not update its own complications. The group is genuinely part of the plumbing on both bundles and is now entitled on both. **The Wear manifest declared no INTERNET permission.** It receives only the scanned permissions, not the base ones, and is selected outright rather than merged -- so a watchMain making an ordinary Codename One network request failed while the same code worked on the phone. **The watch bundle declared no cn1surface URL scheme.** A complication supplies a cn1surface:// widgetURL and the generated scene waits for it in onOpenURL, but the watch is a separate bundle inheriting none of the phone's URL types. watchOS had nothing to route the tap to, so the whole tap-dispatch path was inert. Two new tests pin the module boundary: that a companion phone module carries no androidx.wear dependency, and that the watch bundle declares the scheme when it hosts a complication and does not otherwise. SpotBugs zero across android, ios and codenameone-maven-plugin; 920 plugin tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 136 +++++++++++------- .../com/codename1/builders/IPhoneBuilder.java | 9 +- .../builders/WatchNativeBuilder.java | 11 ++ .../AndroidWatchSurfaceCodegenTest.java | 30 ++++ .../WatchWidgetExtensionTargetTest.java | 21 +++ 5 files changed, 152 insertions(+), 55 deletions(-) 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 dd7f3f4196a..39b262f2faf 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 @@ -319,6 +319,11 @@ public File getGradleProjectDirectory() { /// 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; /// True when the app references com.codename1.intents. Gates the shortcut resources, the @@ -3393,55 +3398,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. - // 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; - } - 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); - } - } - // 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 @@ -3624,6 +3580,11 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { 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. // @@ -3635,6 +3596,61 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } } + // 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; + } + 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. @@ -6672,7 +6688,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } generateWearModule(request, studioProjectDir, gradleProps, watchSurfacesManifestEntries, - permissions + xPermissions, intVersion, wearableListenerService); + basePermissions + permissions + xPermissions, intVersion, wearableListenerService); String rootGradleProps = "// Top-level build file where you can add configuration options common to all sub-projects/modules.\n" + "buildscript {\n" + @@ -7187,8 +7203,12 @@ private void addWatchSurfaceDependencies(BuildRequest request) throws BuildExcep deps.append(" ").append(compile) .append(" 'androidx.concurrent:concurrent-futures:1.1.0'\n"); } - request.putArgument("gradleDependencies", - request.getArg("gradleDependencies", "") + "\n" + deps); + // 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.toString(); // 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. @@ -7224,7 +7244,10 @@ private void addWatchSurfaceDependencies(BuildRequest request) throws BuildExcep * @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 + * @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 @@ -7296,7 +7319,12 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str + " }\n") // Libraries are shared from the app module rather than copied. .replace("fileTree(dir: 'libs'", "fileTree(dir: '../app/libs'") - .replace("dirs 'libs'", "dirs '../app/libs'"); + .replace("dirs 'libs'", "dirs '../app/libs'") + // 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. + .replace("dependencies {\n", "dependencies {\n" + watchSurfaceDependencies); try { createFile(new File(wearDir, "build.gradle"), wearGradle.getBytes(StandardCharsets.UTF_8)); 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 3f280e740d8..b49d67183ed 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 @@ -4173,7 +4173,14 @@ 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)) { request.putArgument("ios.app_groups", appGroups.length() == 0 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 1f929214827..7c468a6ed95 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 @@ -1442,6 +1442,17 @@ void writeWatchInfoPlist(BuildRequest request, File appSrcDir) throws IOExceptio 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 cn1surface:// 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. + sb.append(" CFBundleURLTypes\n \n \n") + .append(" CFBundleURLName\n") + .append(" ").append(escapeXml(request.getPackageName())) + .append(".cn1surface\n") + .append(" CFBundleURLSchemes\n") + .append(" \n cn1surface\n") + .append(" \n \n \n"); } if (isStandalone()) { // A standalone bundle must SAY it is watch-only, not merely omit the companion key. 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 index 1c11841ff36..80e8bb40f22 100644 --- 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 @@ -82,6 +82,36 @@ void theWearModuleCanBeDeclined() { 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)); + } + // --- family to ComplicationData mapping ------------------------------------ /// A watch face asks a data source for ONE type and gets nothing if the source does not 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 index 0a03c404550..00f6bc15e6d 100644 --- 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 @@ -269,6 +269,26 @@ void noComplicationMeansNoTapPlumbing(@TempDir Path tmp) throws Exception { assertFalse(bridging.contains("cn1_watch_surface_url"), bridging); } + /// A complication supplies a cn1surface:// 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. + @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); + assertTrue(plist.contains("cn1surface"), plist); + } + /// A watch app that publishes nothing must not carry either key. @Test void aWatchThatPublishesNothingCarriesNoSurfacesKeys(@TempDir Path tmp) throws Exception { @@ -284,5 +304,6 @@ void aWatchThatPublishesNothingCarriesNoSurfacesKeys(@TempDir Path tmp) throws E assertFalse(plist.contains("CN1SurfacesAppGroup"), plist); assertFalse(plist.contains("CN1SurfacesMinOS"), plist); + assertFalse(plist.contains("cn1surface"), plist); } } From d2c4b524383cb5e28240c68610f27d5ab3c93a60 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:33:23 +0300 Subject: [PATCH 15/96] Keep the Wear module compiling, and pin the file that says how The CI failure and two more review findings, all from the same root: the Wear module's build.gradle is derived from the phone's by textual substitution, and a generated Gradle file only fails when Gradle evaluates it -- twenty minutes after the mistake, in a job that names none of it. **The androidx.wear dependency landed in the buildscript block.** The anchor was "dependencies {", which matches the indented buildscript block FIRST -- and String.replace hits every occurrence -- so an implementation() call went into buildscript's dependency handler, where the method does not exist. The whole :wear project failed to evaluate. Anchored on "\ndependencies {" now, which only the project block matches. **The Wear module looked for a keystore beside itself.** Gradle resolves file("keyStore") relative to the project it appears in, and the certificate is written only to the app module -- so a companion release build failed to CONFIGURE, taking the phone artifact with it. Not the watch half degrading: the whole build not starting. **The generated services were written into the phone's source root.** The wear module shares that directory, so the phone compiled androidx.wear imports it has no libraries for -- the exact mirror of the dependency-scoping fix that preceded it. They now go to the wear module's own root. The kind-list resource stays on the phone deliberately, because the mirror reads it THERE to decide what to send. **The Tile service was copied whether or not a Tile was declared**, while its dependencies were added only for a rectangular family. Gradle compiles every source in the tree, so a complication-only build failed on unresolved imports. The derivation is now a static function, and WearModuleGradleTest pins each substitution against a build.gradle shaped like the real one -- the dependency landing in the project block and not buildscript's, the keystore reachable, the libraries shared, the phone's floor untouched. It calls the real builder rather than reproducing it, and was confirmed to fail on the exact anchor bug that broke CI. Two more tests cover where the services are generated and which are copied. SpotBugs zero across android, ios and codenameone-maven-plugin; 928 plugin tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 149 ++++++++++++++---- .../AndroidWatchSurfaceCodegenTest.java | 55 +++++++ .../builders/WearModuleGradleTest.java | 134 ++++++++++++++++ 3 files changed, 310 insertions(+), 28 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearModuleGradleTest.java 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 39b262f2faf..9b515e2d919 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 @@ -7042,15 +7042,28 @@ private void reportWatchSurfaces(BuildRequest request) { */ private String generateWatchSurfaces(BuildRequest request, File srcDir, File resDir) throws BuildException { - if (watchSurfaceKinds.isEmpty() || watchModuleName(request) == null) { + String module = watchModuleName(request); + if (watchSurfaceKinds.isEmpty() || module == null) { return ""; } - File implDir = new File(srcDir, "com/codename1/impl/android"); + // 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(srcDir, "com/codename1/impl/android/surfaces"); + File surfacesDir = new File(watchSrcDir, "com/codename1/impl/android/surfaces"); surfacesDir.mkdirs(); - for (String resource : new String[] {"CN1ComplicationDataSource.java", - "CN1SurfaceTileService.java"}) { + // 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) { @@ -7086,6 +7099,10 @@ private String generateWatchSurfaces(BuildRequest request, File srcDir, File res // 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 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 { @@ -7302,29 +7319,8 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str int wearVersion = wearVersionCode(request, intVersion); log("[wearable] Wear module version code " + wearVersion + " (phone " + intVersion + ")"); - String wearGradle = appGradle - // Same namespace and applicationId; see the method comment. - .replace("versionCode " + intVersion, "versionCode " + wearVersion) - .replaceFirst("minSdkVersion \\d+", "minSdkVersion 26") - // Share the app module's tree instead of duplicating it, and add this module's - // own generated sources on top. - .replace("android {\n", - "android {\n" - + " sourceSets.main {\n" - + " java.srcDirs = ['../app/src/main/java', 'src/main/java']\n" - + " res.srcDirs = ['../app/src/main/res', 'src/main/res']\n" - + " assets.srcDirs = ['../app/src/main/assets']\n" - + " aidl.srcDirs = ['../app/src/main/aidl']\n" - + " manifest.srcFile 'src/main/AndroidManifest.xml'\n" - + " }\n") - // 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 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. - .replace("dependencies {\n", "dependencies {\n" + watchSurfaceDependencies); + String wearGradle = deriveWearGradle(appGradle, intVersion, wearVersion, + watchSurfaceDependencies); try { createFile(new File(wearDir, "build.gradle"), wearGradle.getBytes(StandardCharsets.UTF_8)); @@ -7424,6 +7420,103 @@ private static int parseIntSafe(String value, int 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; + } + + /** + * 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 + */ + static String deriveWearGradle(String appGradle, int intVersion, int wearVersion, + String wearDependencies) { + return appGradle + // Same namespace and applicationId; see the method comment. + .replace("versionCode " + intVersion, "versionCode " + wearVersion) + .replaceFirst("minSdkVersion \\d+", "minSdkVersion 26") + // Share the app module's tree instead of duplicating it, and add this module's + // own generated sources on top. + .replace("android {\n", + "android {\n" + + " sourceSets.main {\n" + + " java.srcDirs = ['../app/src/main/java', 'src/main/java']\n" + + " res.srcDirs = ['../app/src/main/res', 'src/main/res']\n" + + " assets.srcDirs = ['../app/src/main/assets']\n" + + " aidl.srcDirs = ['../app/src/main/aidl']\n" + + " manifest.srcFile 'src/main/AndroidManifest.xml'\n" + + " }\n") + // 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\")") + // 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 String.replace hits every occurrence -- so the plain form + // put an implementation() call inside buildscript's dependency handler, where the + // method does not exist and the whole :wear project failed to evaluate. + .replace("\ndependencies {\n", "\ndependencies {\n" + wearDependencies); + } + /** Joins declared families for the codegen tables, normalized to the portable spelling. */ static String joinFamilies(List families) { StringBuilder sb = new StringBuilder(); 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 index 80e8bb40f22..2368bc79dff 100644 --- 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 @@ -24,6 +24,10 @@ import org.junit.jupiter.api.Test; +import java.io.File; +import java.util.ArrayList; +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.assertNull; @@ -112,6 +116,57 @@ void aStandaloneModuleIsTheWatchProductAndTakesBoth() { 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 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..5a1284d9de2 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearModuleGradleTest.java @@ -0,0 +1,134 @@ +/* + * 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; + +/// 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" + + " 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"; + + private static final String WEAR_DEPS = + " implementation 'androidx.wear.watchface:watchface-complications-data-source:1.2.1'\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 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); + } + + /// The watch outranks the phone so Play picks it on a watch, and only the wear module takes + /// the 26 floor the androidx.wear libraries require. + @Test + void theWearModuleCarriesItsOwnVersionCodeAndFloor() { + String wear = deriveWearGradle(); + + assertTrue(wear.contains("versionCode 101"), wear); + assertTrue(wear.contains("minSdkVersion 26"), wear); + assertEquals(24, Integer.parseInt( + PHONE_GRADLE.split("minSdkVersion ")[1].split("\n")[0].trim()), + "the phone module keeps its own floor"); + } +} From 81f858418bfabbadd2ab94ae7366f28c366db603 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:04:40 +0300 Subject: [PATCH 16/96] Compile the Data Layer glue, and fix the two errors that found The Android legs failed on :app:compileDebugJavaWithJavac with two errors in CN1WearableListenerService: MessageEvent has no freeze(), and DataMapItem was never imported. Both are older than this branch. The pair is typed against play-services-wearable, which this repository does not depend on, so it ships as a .java resource and no build here ever compiled it -- a broken edit reached a customer's Gradle build naming a file they never wrote. Turning usesWearable on for watch surfaces is what finally compiled it. Only DataEvent is Freezable; MessageEvent is a plain four-method interface, so the hand-off to the worker thread now copies the four accessors into FrozenMessageEvent rather than asking for a freeze() that does not exist. Play services documents the delivered event as valid only for the duration of the callback, and every read happens after it has returned, so the copy is what makes the hand-off safe rather than merely what compiles. WearableGlueCompilesTest closes the hole that let this survive: it compiles both injected files against the REAL CN1SurfaceMirror from the port, so a drift in the mirror hand-off fails here rather than on a device, plus a stub tree for the Android and Play services types. The stubs mirror the real API rather than satisfying the caller -- MessageEvent deliberately does not extend Freezable and DataEvent does -- so a stub written to make an error disappear is a bug in the stub. Verified by reintroducing both errors: the test reports exactly the two CI reported. Co-Authored-By: Claude Opus 5 (1M context) --- .../wearable/CN1WearableListenerService.java | 55 +++++- .../builders/WearableGlueCompilesTest.java | 182 ++++++++++++++++++ .../android/app/Service.javas | 6 + .../android/content/Context.javas | 12 ++ .../android/content/Intent.javas | 18 ++ .../android/content/SharedPreferences.javas | 22 +++ .../android/content/pm/ApplicationInfo.javas | 4 + .../android/content/pm/PackageManager.javas | 4 + .../wearable-glue-stubs/android/net/Uri.javas | 14 ++ .../android/os/Looper.javas | 5 + .../android/util/Base64.javas | 7 + .../android/util/Log.javas | 10 + .../android/gms/common/data/Freezable.javas | 5 + .../gms/tasks/OnCompleteListener.javas | 2 + .../android/gms/tasks/OnFailureListener.javas | 2 + .../com/google/android/gms/tasks/Task.javas | 9 + .../com/google/android/gms/tasks/Tasks.javas | 11 ++ .../google/android/gms/wearable/Asset.javas | 8 + .../gms/wearable/CapabilityClient.javas | 10 + .../android/gms/wearable/CapabilityInfo.javas | 5 + .../android/gms/wearable/DataClient.javas | 16 ++ .../android/gms/wearable/DataEvent.javas | 8 + .../gms/wearable/DataEventBuffer.javas | 7 + .../android/gms/wearable/DataItem.javas | 8 + .../android/gms/wearable/DataItemAsset.javas | 5 + .../android/gms/wearable/DataItemBuffer.javas | 7 + .../google/android/gms/wearable/DataMap.javas | 26 +++ .../android/gms/wearable/DataMapItem.javas | 6 + .../android/gms/wearable/MessageClient.javas | 5 + .../android/gms/wearable/MessageEvent.javas | 13 ++ .../google/android/gms/wearable/Node.javas | 6 + .../android/gms/wearable/NodeClient.javas | 6 + .../gms/wearable/PutDataMapRequest.javas | 9 + .../android/gms/wearable/PutDataRequest.javas | 11 ++ .../android/gms/wearable/Wearable.javas | 7 + .../wearable/WearableListenerService.javas | 10 + 36 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/app/Service.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/Context.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/Intent.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/SharedPreferences.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/pm/ApplicationInfo.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/pm/PackageManager.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/net/Uri.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/os/Looper.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/util/Base64.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/util/Log.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/common/data/Freezable.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnCompleteListener.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnFailureListener.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Task.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Tasks.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Asset.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityClient.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityInfo.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataClient.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEvent.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEventBuffer.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItem.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemAsset.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemBuffer.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMap.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMapItem.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageClient.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageEvent.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Node.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/NodeClient.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataMapRequest.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataRequest.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Wearable.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/WearableListenerService.javas 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 898fd456275..c5cbf3f5c98 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); @@ -303,6 +308,54 @@ 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 payload of a mirrored surface item. * diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java new file mode 100644 index 00000000000..787bd5a2fd2 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java @@ -0,0 +1,182 @@ +/* + * 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 Data Layer glue has to compile. + * + *

{@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 three 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" + + "}\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")); + 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/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/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() { } +} From b4a130ab5630a8632462abe146b5f48cdc31702f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:45:33 +0300 Subject: [PATCH 17/96] Make the Wear module compile: three separate causes :wear:compileDebugJavaWithJavac failed with 132 errors. Most named files under app/, which is the shared source set doing its job -- the wear module compiles the phone tree -- and hid that there were three unrelated faults. A final lifecycle class. The stub keeps the app object in a field typed to the app's main class and asks whether it also implements PushCallback, PushActionsProvider or LocalNotificationCallback. Kotlin classes are final unless they say open, and javac rejects a probe it can prove impossible, so the watch stub -- rooted at a Kotlin watch class -- would not compile. This is not new and not watch-specific: a phone app whose main class is a final Kotlin class has always hit it. The six sites now go through Object, which is legal for any type and behaves identically at runtime. StubLifecycleCastTest pins all six. An empty jar. 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 is dropped. CameraX's graph does exactly that, so the import stopped resolving in a generated Tile service. Verified by resolving the real graph in Gradle -- 1.0 -> 9999.0 -- and that the two jars hold one class and none. The wear module now forces 1.0, but only when the phone module declares no full Guava, because then the marker is right and forcing would duplicate the class at dex time instead. An unpinned second module. The CI harness pins compileSdk for app/ alone. The wear module kept the newest platform on the runner, and API 37 has dropped FingerprintManager, which the port still compiles against. --app now repeats and build-android-app.sh passes the wear module when it exists. Two more from the same family as the buildscript-dependencies bug: "android {" and top-level "dependencies {" both occur twice in a generated build.gradle, and String.replace rewrote every one -- putting the source set in a coverage harness's block and the wear libraries in the instrumentation block. insertAfterFirst says the intent instead. The stub re-rooting also missed ".this"; rather than wait for the next construct to surface as a remote Gradle error, generation now fails naming what it could not rewrite. Every new test verified to fail with its fix reverted. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 139 ++++++++++++++---- .../builders/StubLifecycleCastTest.java | 85 +++++++++++ .../builders/WearModuleGradleTest.java | 92 +++++++++++- scripts/android/lib/PatchGradleFiles.java | 60 ++++++-- scripts/build-android-app.sh | 12 +- 5 files changed, 345 insertions(+), 43 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/StubLifecycleCastTest.java 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 9b515e2d919..ede29f5e3b8 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 @@ -4931,7 +4931,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" @@ -4951,7 +4951,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" @@ -5436,13 +5436,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) { @@ -7299,6 +7299,11 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str .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 @@ -7307,6 +7312,20 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str 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 { @@ -7482,21 +7501,10 @@ static List watchSurfaceSources(List kinds) { */ static String deriveWearGradle(String appGradle, int intVersion, int wearVersion, String wearDependencies) { - return appGradle + String gradle = appGradle // Same namespace and applicationId; see the method comment. .replace("versionCode " + intVersion, "versionCode " + wearVersion) .replaceFirst("minSdkVersion \\d+", "minSdkVersion 26") - // Share the app module's tree instead of duplicating it, and add this module's - // own generated sources on top. - .replace("android {\n", - "android {\n" - + " sourceSets.main {\n" - + " java.srcDirs = ['../app/src/main/java', 'src/main/java']\n" - + " res.srcDirs = ['../app/src/main/res', 'src/main/res']\n" - + " assets.srcDirs = ['../app/src/main/assets']\n" - + " aidl.srcDirs = ['../app/src/main/aidl']\n" - + " manifest.srcFile 'src/main/AndroidManifest.xml'\n" - + " }\n") // Libraries are shared from the app module rather than copied. .replace("fileTree(dir: 'libs'", "fileTree(dir: '../app/libs'") .replace("dirs 'libs'", "dirs '../app/libs'") @@ -7505,16 +7513,91 @@ static String deriveWearGradle(String appGradle, int intVersion, int wearVersion // 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\")") - // 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 String.replace hits every occurrence -- so the plain form - // put an implementation() call inside buildscript's dependency handler, where the - // method does not exist and the whole :wear project failed to evaluate. - .replace("\ndependencies {\n", "\ndependencies {\n" + wearDependencies); + .replace("storeFile file(\"keyStore\")", "storeFile file(\"../app/keyStore\")"); + // 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" + + " res.srcDirs = ['../app/src/main/res', 'src/main/res']\n" + + " assets.srcDirs = ['../app/src/main/assets']\n" + + " aidl.srcDirs = ['../app/src/main/aidl']\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); + return gradle + listenableFutureFix(appGradle, wearDependencies); + } + + /** + * 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); + } + + /** + * Puts a real {@code ListenableFuture} back on the Wear module's compile classpath. + * + *

{@code TileService.onTileRequest} returns a {@code ListenableFuture}, and the tiles and + * concurrent-futures libraries both ask for {@code com.google.guava:listenablefuture:1.0} -- + * a jar holding that one class. Guava publishes a second version of the same coordinate, + * {@code 9999.0-empty-to-avoid-conflict-with-guava}, which contains NO classes at all: it + * exists so that a build carrying full Guava does not end up with the class twice. Any + * dependency that pulls the marker wins the version comparison, the real jar is dropped, and + * the import stops resolving -- which is what CameraX's transitive graph does to a Codename + * One app, with the compile error landing in a generated Tile service the developer never + * wrote.

+ * + *

Forcing 1.0 is right only while nothing supplies the class already. When the app really + * does carry full Guava the marker is doing its job, and forcing would put + * {@code ListenableFuture} in two jars and fail at dex time instead -- so this is emitted + * only when the phone module declares no Guava, which is a question about generated text and + * can be answered here.

+ * + * @param appGradle the phone module's build.gradle, scanned for a full Guava dependency + * @param wearDependencies the Wear dependency block; empty when no Tile is generated + * @return the Gradle snippet to append, or an empty string when it is not needed + */ + private static String listenableFutureFix(String appGradle, String wearDependencies) { + if (wearDependencies == null + || wearDependencies.indexOf("androidx.concurrent:concurrent-futures") < 0) { + return ""; + } + if (appGradle.indexOf("com.google.guava:guava:") >= 0) { + return ""; + } + return "\n// See listenableFutureFix: the empty 9999.0 marker artifact would otherwise\n" + + "// win over the 1.0 jar that actually holds ListenableFuture.\n" + + "configurations.all {\n" + + " resolutionStrategy.force 'com.google.guava:listenablefuture:1.0'\n" + + "}\n"; } /** Joins declared families for the codegen tables, normalized to the portable spelling. */ 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/WearModuleGradleTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearModuleGradleTest.java index 5a1284d9de2..4bd0f973761 100644 --- 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 @@ -26,6 +26,7 @@ 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 @@ -71,10 +72,28 @@ class WearModuleGradleTest { + "\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.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"; /// 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 @@ -83,6 +102,77 @@ 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); + } + + /// TileService.onTileRequest returns a ListenableFuture, and the class lives in + /// com.google.guava:listenablefuture:1.0. Guava publishes an EMPTY jar under the same + /// coordinate at version 9999.0-empty-to-avoid-conflict-with-guava, for builds that carry + /// full Guava. Anything pulling the marker wins the comparison and the real jar is dropped -- + /// CameraX's graph does exactly that -- and the import stops resolving in a generated Tile + /// service the developer never wrote. + @Test + void aRealListenableFutureIsForcedBackOntoTheClasspath() { + String wear = deriveWearGradle(); + + assertTrue(wear.contains("resolutionStrategy.force 'com.google.guava:listenablefuture:1.0'"), + "the empty marker artifact would win without this:\n" + wear); + } + + /// ...but only while nothing supplies the class already. When the app carries full Guava the + /// marker is doing its job, and forcing 1.0 would put ListenableFuture in two jars and fail + /// at dex time instead. + @Test + void theForceIsOmittedWhenTheAppAlreadyCarriesGuava() { + String withGuava = PHONE_GRADLE.replace( + " implementation fileTree(dir: 'libs', include: ['*.jar'])\n", + " implementation fileTree(dir: 'libs', include: ['*.jar'])\n" + + " implementation 'com.google.guava:guava:33.0.0-android'\n"); + + String wear = AndroidGradleBuilder.deriveWearGradle(withGuava, 100, 101, WEAR_DEPS); + + assertFalse(wear.contains("resolutionStrategy.force"), + "forcing on top of full Guava duplicates the class:\n" + wear); + } + + /// A kind with no rectangular family generates no Tile, so concurrent-futures is not added + /// and there is no ListenableFuture to rescue. + @Test + void theForceIsOmittedWhenNoTileIsGenerated() { + String complicationOnly = + " implementation 'androidx.wear.watchface:watchface-complications-data-source:1.2.1'\n"; + + String wear = AndroidGradleBuilder.deriveWearGradle(PHONE_GRADLE, 100, 101, complicationOnly); + + assertFalse(wear.contains("resolutionStrategy.force"), + "nothing here needs ListenableFuture:\n" + wear); + } + /// 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 diff --git a/scripts/android/lib/PatchGradleFiles.java b/scripts/android/lib/PatchGradleFiles.java index 2d9252d6005..062071026ac 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,26 @@ 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; + } + if (patchAppBuildGradle(module, arguments.compileSdk, arguments.targetSdk)) { + System.out.println("Patched " + module); + modifiedAny = true; + } } - if (!modifiedRoot && !modifiedApp) { + if (!modifiedAny) { System.out.println("Gradle files already normalized"); } } @@ -357,20 +390,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 +422,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 +444,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..70e6b5b8315 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 --- From d0e7d59f4ca8c62b19fb0cfeaaa8205e2b869fc9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:00:15 +0300 Subject: [PATCH 18/96] Give ListenableFuture a provider instead of forcing one The previous commit forced com.google.guava:listenablefuture back to 1.0 so the Tile service could see ListenableFuture. That fixed the default Android build and broke the car-enabled one: androidx.car.app brings full Guava 31.1-android, which carries the same class, so :wear:checkDebugDuplicateClasses failed. The guard meant to prevent exactly that read the phone build.gradle for a declared Guava, and Guava arrives transitively -- text cannot answer a question about a resolved graph. The empty 9999.0 marker is not a bug to defeat. It says "full Guava supplies this", and the fix is to make that true: the Wear module now depends on Guava whenever a Tile is generated, the marker wins as designed, and there is exactly one provider either way. The floor is low and hint-overridable so a project already on a newer Guava keeps it, and R8 takes the unused bulk back out of a release build. Verified in a real Gradle resolution rather than by reasoning about POMs: with the CameraX and car.app graph in place, a probe importing ListenableFuture and CallbackToFutureAdapter compiles and checkDebugDuplicateClasses passes, and removing the Guava line alone puts the compile error back. The dependency block is now a pure function so the pairing that matters -- every Tile line arriving together, Guava included -- is pinned directly rather than inferred from the generated file. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 121 +++++++++--------- .../builders/WearModuleGradleTest.java | 69 +++++----- 2 files changed, 98 insertions(+), 92 deletions(-) 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 ede29f5e3b8..9bcc61cb707 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 @@ -7190,42 +7190,85 @@ private String tileServiceEntry(String className, String label) { * {@code Futures.immediateFuture}, so {@code CallbackToFutureAdapter} is the reliable * Java-only route.

*/ - 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"; + /** + * 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(request.getArg("android.wear.complicationsVersion", "1.2.1")) - .append("'\n"); - boolean anyTile = false; - for (String[] kind : watchSurfaceKinds) { - if (declaresTile(kind[2])) { - anyTile = true; - break; - } - } + .append(complicationsVersion).append("'\n"); if (anyTile) { deps.append(" ").append(compile).append(" 'androidx.wear.tiles:tiles:") - .append(request.getArg("android.wear.tilesVersion", "1.4.1")).append("'\n"); + .append(tilesVersion).append("'\n"); deps.append(" ").append(compile) .append(" 'androidx.wear.protolayout:protolayout:") - .append(request.getArg("android.wear.protoLayoutVersion", "1.2.1")) - .append("'\n"); + .append(protoLayoutVersion).append("'\n"); deps.append(" ").append(compile) .append(" 'androidx.wear.protolayout:protolayout-material:") - .append(request.getArg("android.wear.protoLayoutVersion", "1.2.1")) - .append("'\n"); + .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.toString(); + 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. @@ -7536,7 +7579,7 @@ static String deriveWearGradle(String appGradle, int intVersion, int wearVersion // 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); - return gradle + listenableFutureFix(appGradle, wearDependencies); + return gradle; } /** @@ -7562,44 +7605,6 @@ private static String insertAfterFirst(String text, String anchor, String insert return text.substring(0, after) + insertion + text.substring(after); } - /** - * Puts a real {@code ListenableFuture} back on the Wear module's compile classpath. - * - *

{@code TileService.onTileRequest} returns a {@code ListenableFuture}, and the tiles and - * concurrent-futures libraries both ask for {@code com.google.guava:listenablefuture:1.0} -- - * a jar holding that one class. Guava publishes a second version of the same coordinate, - * {@code 9999.0-empty-to-avoid-conflict-with-guava}, which contains NO classes at all: it - * exists so that a build carrying full Guava does not end up with the class twice. Any - * dependency that pulls the marker wins the version comparison, the real jar is dropped, and - * the import stops resolving -- which is what CameraX's transitive graph does to a Codename - * One app, with the compile error landing in a generated Tile service the developer never - * wrote.

- * - *

Forcing 1.0 is right only while nothing supplies the class already. When the app really - * does carry full Guava the marker is doing its job, and forcing would put - * {@code ListenableFuture} in two jars and fail at dex time instead -- so this is emitted - * only when the phone module declares no Guava, which is a question about generated text and - * can be answered here.

- * - * @param appGradle the phone module's build.gradle, scanned for a full Guava dependency - * @param wearDependencies the Wear dependency block; empty when no Tile is generated - * @return the Gradle snippet to append, or an empty string when it is not needed - */ - private static String listenableFutureFix(String appGradle, String wearDependencies) { - if (wearDependencies == null - || wearDependencies.indexOf("androidx.concurrent:concurrent-futures") < 0) { - return ""; - } - if (appGradle.indexOf("com.google.guava:guava:") >= 0) { - return ""; - } - return "\n// See listenableFutureFix: the empty 9999.0 marker artifact would otherwise\n" - + "// win over the 1.0 jar that actually holds ListenableFuture.\n" - + "configurations.all {\n" - + " resolutionStrategy.force 'com.google.guava:listenablefuture:1.0'\n" - + "}\n"; - } - /** Joins declared families for the codegen tables, normalized to the portable spelling. */ static String joinFamilies(List families) { StringBuilder sb = new StringBuilder(); 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 index 4bd0f973761..8814e082e66 100644 --- 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 @@ -93,7 +93,8 @@ class WearModuleGradleTest { 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 '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 @@ -130,47 +131,47 @@ void theWearDependencyIsInsertedExactlyOnce() { "the instrumentation block must still follow, untouched:\n" + wear); } - /// TileService.onTileRequest returns a ListenableFuture, and the class lives in - /// com.google.guava:listenablefuture:1.0. Guava publishes an EMPTY jar under the same - /// coordinate at version 9999.0-empty-to-avoid-conflict-with-guava, for builds that carry - /// full Guava. Anything pulling the marker wins the comparison and the real jar is dropped -- - /// CameraX's graph does exactly that -- and the import stops resolving in a generated Tile - /// service the developer never wrote. + /// 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 aRealListenableFutureIsForcedBackOntoTheClasspath() { - String wear = deriveWearGradle(); - - assertTrue(wear.contains("resolutionStrategy.force 'com.google.guava:listenablefuture:1.0'"), - "the empty marker artifact would win without this:\n" + wear); + 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); } - /// ...but only while nothing supplies the class already. When the app carries full Guava the - /// marker is doing its job, and forcing 1.0 would put ListenableFuture in two jars and fail - /// at dex time instead. + /// A kind with no rectangular family earns no Tile, and then none of it is needed. @Test - void theForceIsOmittedWhenTheAppAlreadyCarriesGuava() { - String withGuava = PHONE_GRADLE.replace( - " implementation fileTree(dir: 'libs', include: ['*.jar'])\n", - " implementation fileTree(dir: 'libs', include: ['*.jar'])\n" - + " implementation 'com.google.guava:guava:33.0.0-android'\n"); - - String wear = AndroidGradleBuilder.deriveWearGradle(withGuava, 100, 101, WEAR_DEPS); - - assertFalse(wear.contains("resolutionStrategy.force"), - "forcing on top of full Guava duplicates the class:\n" + wear); + 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 kind with no rectangular family generates no Tile, so concurrent-futures is not added - /// and there is no ListenableFuture to rescue. + /// A legacy support-library build writes "compile" throughout, and these lines have to match + /// the block they are inserted into. @Test - void theForceIsOmittedWhenNoTileIsGenerated() { - String complicationOnly = - " implementation 'androidx.wear.watchface:watchface-complications-data-source:1.2.1'\n"; - - String wear = AndroidGradleBuilder.deriveWearGradle(PHONE_GRADLE, 100, 101, complicationOnly); + void theDependencyKeywordFollowsTheBuild() { + String deps = AndroidGradleBuilder.watchSurfaceDependencyBlock( + "compile", true, "1.2.1", "1.4.1", "1.2.1", "31.1-android"); - assertFalse(wear.contains("resolutionStrategy.force"), - "nothing here needs ListenableFuture:\n" + wear); + 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 From 25cebc85c1f40a9f379c8d9266ac6c237c89599d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:09:04 +0300 Subject: [PATCH 19/96] Round five review: six findings across the Wear half Progress from a date interval. SurfaceProgress.setDateInterval writes start/end and no value, and the watch reader checked only value/valueKey -- so a ranged complication reported no data and a Tile drew a zero-length arc. It now calls the renderer's own resolveFraction rather than carrying a second copy of the arithmetic; what stays here is the emptiness question, because a widget always has a bar to draw and treats an unusable node as zero, while a gauge pinned at the bottom is a claim about the value rather than an absence of one. Mirrored images were never collected. Blob names are content hashes, so every changed image left its predecessor in the watch app's storage for ever. The mirror receiver now runs the same collection the publish path does, after the replacement timeline is safely written. reloadWidgets did nothing for a watch. It called broadcastUpdate, which reaches home-screen providers only, so a reload of a watch-only kind was a no-op and a mixed kind refreshed half of itself. Tile spacers were all 4dp. SurfaceSpacer serializes "min", and only when non-zero; reading w/h turned every declared spacer into the same stub and every flexible one along with it. The parent axis is threaded down because the same node is a width in a row and a height in a column, and a spacer with no minimum is now expand() rather than four dips -- which is what it was for. Complications never advanced. A timeline can hold entries that take over at stated times, the service is asked once, and UPDATE_PERIOD_SECONDS is deliberately 0 because polling a push-driven surface costs battery for nothing. The data now carries a valid time range ending at the flip, so the system comes back at that moment and nowhere in between. Tiles cached stale artwork. The resources version hashed image ids, and a vector's id covers its own definition but not the entry state its ops read -- so a flip that only moved a hand advertised the same version and Wear kept the old bitmap. The version now includes the state, and both sites that compute it share one implementation instead of two that had already diverged. The Tile trampoline is exported only where a Tile exists. ProtoLayout's LaunchAction is started by the tile host from its own process, so a private activity fails the permission check and the tap does nothing; a complication is unaffected because its PendingIntent was created by the app. A project with complications and no Tile keeps the activity private. A Wear version code that does not outrank the phone is refused, naming the hint: a watch would otherwise install the phone build, which is not an error anyone would trace back to a setting. The Wear module gets the mobile-services config. Its build.gradle is derived from the phone's, so an FCM app carried the google-services plugin into a module with no config file and failed the whole multi-module build -- taking the phone artifact with it. Copied rather than stripped, because the derived dependency block already carries Firebase. The injected services and the port reader were typechecked against the real androidx.wear jars, not only the stub tree; the stubs were corrected to match where they had been looser than the API. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/AndroidSurfaceBridge.java | 6 + .../android/surfaces/CN1SurfaceMirror.java | 6 + .../android/surfaces/CN1SurfaceRenderer.java | 16 ++- .../android/surfaces/CN1SurfaceStore.java | 9 +- .../android/surfaces/CN1WatchSurface.java | 35 +++--- .../builders/AndroidGradleBuilder.java | 103 ++++++++++++++++-- .../wear/CN1ComplicationDataSource.java | 85 ++++++++++++--- .../surfaces/wear/CN1SurfaceTileService.java | 54 +++++++-- .../AndroidWatchSurfaceCodegenTest.java | 34 +++++- .../builders/WearGlueCompilesTest.java | 2 + .../builders/WearableGlueCompilesTest.java | 1 + .../wear/protolayout/DimensionBuilders.javas | 10 +- .../protolayout/LayoutElementBuilders.javas | 4 +- .../data/LongTextComplicationData.javas | 1 + .../MonochromaticImageComplicationData.javas | 1 + .../data/RangedValueComplicationData.javas | 1 + .../data/ShortTextComplicationData.javas | 1 + .../complications/data/TimeRange.javas | 7 ++ .../org/json/JSONObject.javas | 1 + 19 files changed, 319 insertions(+), 58 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeRange.javas 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 63190b88063..7cc140cf9d1 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java @@ -128,10 +128,16 @@ 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); return; } for (String kind : CN1SurfaceStore.getRememberedKinds(ctx)) { broadcastUpdate(ctx, kind); + CN1WatchSurfaceNotifier.requestUpdate(ctx, kind); } } diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index 0879d54e973..f6f28ab0afe 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -182,6 +182,12 @@ public static void receive(Context ctx, String path, byte[] payload) { 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. + CN1SurfaceStore.deleteUnreferencedImages(kindDir, new String(json, "UTF-8")); CN1WatchSurfaceNotifier.requestUpdate(ctx, kindId); } catch (Throwable t) { Log.w(TAG, "Could not apply a mirrored surface from " + path, t); 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 fa5aff4035d..58d2ef43bbe 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java @@ -941,11 +941,21 @@ 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) { 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. 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..d651ee8281b 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,14 @@ 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) { try { org.json.JSONObject doc = new org.json.JSONObject(timelineJson); org.json.JSONArray names = doc.optJSONArray("images"); diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java index f7c9c0a170c..0f90d4ece2d 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java @@ -306,8 +306,21 @@ public static long dynamicDate(JSONObject node, JSONObject state) { /** * A progress node's value, clamped to 0..1. * - *

Either literal or read from the entry's state by key, matching what the renderer does - * for a progress bar on every other platform.

+ *

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 @@ -317,20 +330,14 @@ public static float progressValue(JSONObject prog, JSONObject state) { if (prog == null) { return -1f; } - double value; - if (prog.has("value")) { - value = prog.optDouble("value", -1); - } else { - String key = prog.optString("valueKey", ""); - if (key.length() == 0 || state == null || !state.has(key)) { - return -1f; - } - value = state.optDouble(key, -1); - } - if (value < 0) { + 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) Math.min(1.0, value); + return (float) CN1SurfaceRenderer.resolveFraction(prog, state); } /** 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 9bcc61cb707..da2398e4111 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 @@ -3556,9 +3556,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"); @@ -7190,6 +7193,32 @@ private String tileServiceEntry(String className, String label) { * {@code Futures.immediateFuture}, so {@code CallbackToFutureAdapter} is the reliable * Java-only route.

*/ + /** + * 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. * @@ -7389,6 +7418,7 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str } catch (IOException ex) { throw new BuildException("Failed to write the Wear module build.gradle", ex); } + copyMobileServiceConfig(studioProjectDir, wearDir); String wearManifest = "\n" + "\n" @@ -7465,13 +7496,32 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str * @param intVersion the phone's version code * @return the wear module's version code */ - static int wearVersionCode(BuildRequest request, int intVersion) { + static int wearVersionCode(BuildRequest request, int intVersion) throws BuildException { String explicit = request.getArg("android.watchVersionCode", ""); + int resolved; + String setting; if (explicit.length() > 0) { - return parseIntSafe(explicit, intVersion + 1); - } - return intVersion + parseIntSafe( - request.getArg("android.watchVersionCodeOffset", "1"), 1); + resolved = parseIntSafe(explicit, intVersion + 1); + setting = "android.watchVersionCode=" + explicit; + } else { + resolved = intVersion + parseIntSafe( + request.getArg("android.watchVersionCodeOffset", "1"), 1); + setting = "android.watchVersionCodeOffset=" + + request.getArg("android.watchVersionCodeOffset", "1"); + } + // 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) { @@ -7528,6 +7578,43 @@ static List watchSurfaceSources(List kinds) { 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. * 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 index 0cae09869a0..a3a7850aa2b 100644 --- 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 @@ -39,11 +39,13 @@ 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.TimeRange; import androidx.wear.watchface.complications.datasource.ComplicationDataSourceService; import androidx.wear.watchface.complications.datasource.ComplicationRequest; import org.json.JSONObject; +import java.time.Instant; import java.util.List; /** @@ -103,8 +105,9 @@ public ComplicationData getPreviewData(ComplicationType type) { 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. - return shortText(getKindId(), null, null); + // 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. + return shortText(getKindId(), null, null, null); } /** @@ -133,15 +136,27 @@ private ComplicationData build(ComplicationType type) { List texts = CN1WatchSurface.texts(nodes, reading.getState()); PendingIntent tap = tapIntent(reading.getLayout()); reportDroppedContent(nodes, texts); + // How long this answer is good for. A published timeline can hold entries that take over + // at stated times, and this service is asked once and then not again: UPDATE_PERIOD_SECONDS + // is deliberately 0, because polling a push-driven surface costs watch battery for nothing. + // Without a validity the face would keep the first entry for ever and the later ones would + // never appear. Saying when the data stops being true asks the system to come back at that + // moment and nowhere in between, which is the same bargain the Tile's freshness interval + // makes. + TimeRange validity = validityFor(reading.getNextFlipDate()); if (ComplicationType.LONG_TEXT.equals(type)) { String title = texts.isEmpty() ? getKindId() : texts.get(0); String body = texts.size() > 1 ? join(texts, 1) : ""; - return new LongTextComplicationData.Builder(plain(body.length() == 0 ? title : body), - plain(title)) - .setTitle(body.length() == 0 ? null : plain(title)) - .setTapAction(tap) - .build(); + LongTextComplicationData.Builder builder = + new LongTextComplicationData.Builder(plain(body.length() == 0 ? title : body), + plain(title)) + .setTitle(body.length() == 0 ? null : plain(title)) + .setTapAction(tap); + if (validity != null) { + builder.setValidTimeRange(validity); + } + return builder.build(); } if (ComplicationType.RANGED_VALUE.equals(type)) { JSONObject prog = CN1WatchSurface.firstOfType(nodes, "prog"); @@ -157,30 +172,64 @@ private ComplicationData build(ComplicationType type) { if (!texts.isEmpty()) { builder.setText(plain(shorten(texts.get(0)))); } - return builder.setTapAction(tap).build(); + builder.setTapAction(tap); + if (validity != null) { + builder.setValidTimeRange(validity); + } + return builder.build(); } if (ComplicationType.MONOCHROMATIC_IMAGE.equals(type)) { Icon icon = monochromeIcon(nodes, reading.getState()); if (icon == null) { return null; } - return new MonochromaticImageComplicationData.Builder( - new MonochromaticImage.Builder(icon).build(), - plain(texts.isEmpty() ? getKindId() : texts.get(0))) - .setTapAction(tap) - .build(); + MonochromaticImageComplicationData.Builder builder = + new MonochromaticImageComplicationData.Builder( + new MonochromaticImage.Builder(icon).build(), + plain(texts.isEmpty() ? getKindId() : texts.get(0))) + .setTapAction(tap); + if (validity != null) { + builder.setValidTimeRange(validity); + } + return builder.build(); } if (ComplicationType.SHORT_TEXT.equals(type)) { if (texts.isEmpty()) { return null; } return shortText(shorten(texts.get(0)), texts.size() > 1 ? shorten(texts.get(1)) : null, - tap); + tap, validity); } return null; } - private ShortTextComplicationData shortText(String text, String title, PendingIntent tap) { + /** + * The window this answer stays true for, or null when it is good indefinitely. + * + *

A flip date in the past or absent means the timeline has one entry and nothing is + * scheduled to replace it, and claiming an expiry then would make the face throw away good + * data and ask again for the same answer.

+ * + * @param flip the next entry's start time, in epoch millis, or 0 when there is none + * @return the validity window, or null + */ + private TimeRange validityFor(long flip) { + long now = System.currentTimeMillis(); + if (flip <= now) { + return null; + } + try { + return TimeRange.between(Instant.ofEpochMilli(now), Instant.ofEpochMilli(flip)); + } catch (Throwable t) { + // Never at the cost of the reading itself: data with no expiry is stale later, + // data that failed to build is absent now. + Log.w(TAG, "Could not bound the complication's validity for kind " + getKindId(), t); + return null; + } + } + + private ShortTextComplicationData shortText(String text, String title, PendingIntent tap, + TimeRange validity) { // The untruncated string becomes the content description, so a screen reader still hears // what the layout said even where the slot shows seven characters. ShortTextComplicationData.Builder builder = @@ -188,7 +237,11 @@ private ShortTextComplicationData shortText(String text, String title, PendingIn if (title != null && title.length() > 0) { builder.setTitle(plain(title)); } - return builder.setTapAction(tap).build(); + builder.setTapAction(tap); + if (validity != null) { + builder.setValidTimeRange(validity); + } + return builder.build(); } private Icon monochromeIcon(List nodes, JSONObject state) { 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 index def95568e72..0866fba2b35 100644 --- 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 @@ -120,9 +120,9 @@ private TileBuilders.Tile buildTile() { if (reading == null) { root = text("No data yet"); } else { - root = render(reading.getLayout(), reading.getState(), 0); + root = render(reading.getLayout(), reading.getState(), 0, false); freshness = freshnessFor(reading.getNextFlipDate()); - version = String.valueOf(imageNames(reading.getLayout()).hashCode()); + version = resourcesVersion(reading); } } catch (Throwable t) { // A Tile that throws is removed from the carousel, so a malformed descriptor must @@ -162,6 +162,28 @@ private static long freshnessFor(long nextFlipDate) { return Math.min(delta, MAX_FRESHNESS_MILLIS); } + /** + * 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 + */ + private static String resourcesVersion(CN1WatchSurface.Reading reading) { + return String.valueOf((imageNames(reading.getLayout()).toString() + + "|" + String.valueOf(reading.getState())).hashCode()); + } + private ResourceBuilders.Resources buildResources() { ResourceBuilders.Resources.Builder builder = new ResourceBuilders.Resources.Builder(); String version = "0"; @@ -169,7 +191,7 @@ private ResourceBuilders.Resources buildResources() { CN1WatchSurface.Reading reading = CN1WatchSurface.read(this, getKindId(), "watchRectangular"); if (reading != null) { - version = String.valueOf(imageNames(reading.getLayout()).hashCode()); + version = resourcesVersion(reading); for (Map.Entry e : imageNodes(reading.getLayout()).entrySet()) { Bitmap bitmap = CN1WatchSurface.bitmap(this, getKindId(), e.getValue(), @@ -200,7 +222,7 @@ private ResourceBuilders.Resources buildResources() { // --- node tree to ProtoLayout ------------------------------------------------ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject state, - int depth) { + int depth, boolean inRow) { if (node == null || depth > 8) { return text(""); } @@ -208,28 +230,42 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s if ("col".equals(type)) { LayoutElementBuilders.Column.Builder col = new LayoutElementBuilders.Column.Builder(); for (JSONObject child : children(node)) { - col.addContent(render(child, state, depth + 1)); + col.addContent(render(child, state, depth + 1, false)); } return col.setModifiers(modifiers(node)).build(); } if ("row".equals(type)) { LayoutElementBuilders.Row.Builder row = new LayoutElementBuilders.Row.Builder(); for (JSONObject child : children(node)) { - row.addContent(render(child, state, depth + 1)); + row.addContent(render(child, state, depth + 1, true)); } return row.setModifiers(modifiers(node)).build(); } if ("box".equals(type)) { LayoutElementBuilders.Box.Builder box = new LayoutElementBuilders.Box.Builder(); for (JSONObject child : children(node)) { - box.addContent(render(child, state, depth + 1)); + box.addContent(render(child, state, depth + 1, inRow)); } return box.setModifiers(modifiers(node)).build(); } 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); return new LayoutElementBuilders.Spacer.Builder() - .setWidth(DimensionBuilders.dp(Math.max(1, node.optInt("w", 4)))) - .setHeight(DimensionBuilders.dp(Math.max(1, node.optInt("h", 4)))) + .setWidth(inRow ? along : across) + .setHeight(inRow ? across : along) .build(); } if ("img".equals(type) || "vec".equals(type)) { 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 index 2368bc79dff..77e27306a9f 100644 --- 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 @@ -31,6 +31,7 @@ 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 decisions the Wear complication codegen makes, as pure functions so they can be pinned @@ -220,12 +221,12 @@ void phoneFamiliesContributeNoComplicationTypes() { /// 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() { + void theWearArtifactOutranksThePhoneOne() throws BuildException { assertEquals(101, AndroidGradleBuilder.wearVersionCode(request(), 100)); } @Test - void theWearVersionCodeCanBeSetOutright() { + void theWearVersionCodeCanBeSetOutright() throws BuildException { BuildRequest req = request(); req.putArgument("android.watchVersionCode", "5000"); @@ -233,7 +234,7 @@ void theWearVersionCodeCanBeSetOutright() { } @Test - void theOffsetCanBeWidenedForAProjectThatNumbersItsBuildsTightly() { + void theOffsetCanBeWidenedForAProjectThatNumbersItsBuildsTightly() throws BuildException { BuildRequest req = request(); req.putArgument("android.watchVersionCodeOffset", "50"); @@ -242,13 +243,38 @@ void theOffsetCanBeWidenedForAProjectThatNumbersItsBuildsTightly() { /// A malformed hint must not produce a version code that silently reorders the two artifacts. @Test - void aMalformedVersionHintFallsBackToTheDefault() { + void aMalformedVersionHintFallsBackToTheDefault() throws BuildException { BuildRequest req = request(); req.putArgument("android.watchVersionCodeOffset", "not a number"); assertEquals(101, 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)); + } + // --- tiles ------------------------------------------------------------------ /// Only the rectangular family is roomy enough for a layout rather than a readout, so it is 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 index 63c103ee693..8a35b9755f6 100644 --- 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 @@ -124,6 +124,8 @@ void theInjectedWearServicesCompile(@TempDir Path tmp) throws IOException { + "{ return 0L; }\n" + " static String formatWatchDynamicText(JSONObject n, JSONObject s) " + "{ return \"\"; }\n" + + " static double resolveFraction(JSONObject n, JSONObject s) " + + "{ return 0d; }\n" + "}\n").getBytes("UTF-8")); Files.write(shims.resolve("CN1SurfaceActionActivity.java"), ("package com.codename1.impl.android.surfaces;\n" diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java index 787bd5a2fd2..65033eb8ea6 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java @@ -109,6 +109,7 @@ void theInjectedWearableGlueCompiles(@TempDir Path tmp) throws IOException { + "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" + "}\n").getBytes("UTF-8")); Files.write(shims.resolve("CN1WatchSurface.java"), ("package com.codename1.impl.android.surfaces;\n" 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 index 817e1f0411d..0c9992b01f8 100644 --- 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 @@ -1,9 +1,17 @@ package androidx.wear.protolayout; public final class DimensionBuilders { - public static class DpProp { } + // 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 ImageDimension { } + public static class DpProp implements SpacerDimension, ImageDimension { } + public static class ExpandedDimensionProp implements SpacerDimension, ImageDimension { } public static class SpProp { } public static class DegreesProp { } public static DpProp dp(float v) { return null; } + public static ExpandedDimensionProp expand() { 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 index 44bb5025deb..a9a0a010a1b 100644 --- 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 @@ -42,8 +42,8 @@ public final class LayoutElementBuilders { } public static class Spacer implements LayoutElement { public static class Builder { - public Builder setWidth(DimensionBuilders.DpProp d) { return this; } - public Builder setHeight(DimensionBuilders.DpProp d) { return this; } + public Builder setWidth(DimensionBuilders.SpacerDimension d) { return this; } + public Builder setHeight(DimensionBuilders.SpacerDimension d) { return this; } public Spacer build() { return null; } } } 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 index e237707ee9e..53d13b5328c 100644 --- 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 @@ -2,6 +2,7 @@ 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; } 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 index 1f63483dbfa..e342bf6602a 100644 --- 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 @@ -2,6 +2,7 @@ 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/RangedValueComplicationData.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/RangedValueComplicationData.javas index 94ac84d9848..641f53c3f6d 100644 --- 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 @@ -2,6 +2,7 @@ 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; } 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 index 87425617e93..245ad5e97bd 100644 --- 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 @@ -2,6 +2,7 @@ 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; } 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/org/json/JSONObject.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONObject.javas index e70a9294cd1..87d1ff873c1 100644 --- 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 @@ -9,6 +9,7 @@ public class JSONObject { public int optInt(String k) { return 0; } public long optLong(String k) { return 0L; } 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 ""; } From d08e85b0a40a2c4e55d1826576f9a8604e19f43e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:16:35 +0300 Subject: [PATCH 20/96] Round five review: three findings on the Apple side The watch never activated its session. Every route into CN1WatchConnectivity is a wearable native, so the WCSession comes up lazily the first time an app touches com.codename1.wearable -- and an app that declares watch surfaces and never touches that API takes no such route. The delegate was never installed and didReceiveUserInfo: could not fire, in exactly the surfaces-only configuration the phone-to-watch mirror exists to serve, with nothing to report it because the phone half sends successfully into a session that has no listener. The watch's initVM now brings the session up before the app starts, so a descriptor that arrives during launch is not dropped either. Mirrored artwork was never collected on the watch. 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 native apply path now collects after the replacement document is in place -- the same order IOSSurfaceBridge uses locally -- against the document's own "images" list rather than the blobs that arrived in this message, because a mirror only ships art the watch has not seen and collecting against the transferred names would delete what it was keeping. The extension declared version 1.0/1 whatever the app said. Apple validates an embedded bundle's versions against its container, and this one is nested two deep, so a project on any other version was rejected at submission -- after every build had gone green. Both widget extensions now declare the app's own resolved values, which are not simply the project version: ios.plistInject replaces the default injection where it sets either key, and the two keys are independent, so the build version must not be derived from an injected marketing one. The Matter extension had already worked this out by hand and now shares the same two methods. The new Objective-C was syntax-checked against both the iPhoneOS and watchOS SDKs; the native signature gate reports 0 fatal. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/IOSNative.m | 54 +++++++++++++++++++ .../com/codename1/builders/IPhoneBuilder.java | 51 ++++++++++++++---- .../util/IOSWidgetExtensionBuilder.java | 38 ++++++++++++- .../IOSWidgetExtensionWatchTargetTest.java | 38 +++++++++++++ 4 files changed, 170 insertions(+), 11 deletions(-) diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 4dfb77935ed..307d3a47f6f 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -555,6 +555,13 @@ void com_codename1_impl_ios_IOSNative_initVM__(CN1_THREAD_STATE_MULTI_ARG JAVA_O // // Safe this early because the transition forwarders serial-dispatch onto the EDT, so a phase // released now queues BEHIND the app start this callback just scheduled. +#if defined(CN1_USE_WIDGETS) && defined(CN1_USE_WATCHCONNECTIVITY) + // Before the app starts, so a mirrored complication that arrives while the watch app is + // launching is not dropped. See cn1_watch_activate_connectivity: a surfaces-only watch app + // never calls a wearable native, so nothing else would ever activate the session. + extern void cn1_watch_activate_connectivity(void); + cn1_watch_activate_connectivity(); +#endif extern void cn1_watch_runtime_markJavaReady(void); cn1_watch_runtime_markJavaReady(); while (1) { @@ -15694,6 +15701,37 @@ void cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, NSLog(@"[CN1Surfaces] could not write the mirrored timeline for \"%@\"", kind); return; } + // 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, @@ -16315,6 +16353,22 @@ 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. +// +// 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/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 b49d67183ed..8bd2193cc60 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 @@ -6016,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 = embeddedShortVersion(request); + String extBundle = embeddedBundleVersion(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. @@ -6697,12 +6690,50 @@ private void parseSurfacesManifest(File resDir, BuildRequest request) throws Bui * @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 embeddedShortVersion(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 #embeddedShortVersion}: 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 embeddedBundleVersion(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(embeddedShortVersion(request), + embeddedBundleVersion(request)) .setWatchTarget(true) .setExtensionName(SURFACES_WATCH_EXTENSION_NAME) // The extension is nested in the watch app, so its bundle id extends the WATCH @@ -6744,6 +6775,8 @@ private void writeWatchWidgetExtension(BuildRequest request, File distDir, File */ private void appendWidgetExtensionTargets(StringBuilder sb, BuildRequest request, File distDir) throws IOException { IOSWidgetExtensionBuilder widgetBuilder = new IOSWidgetExtensionBuilder() + .setVersions(embeddedShortVersion(request), + embeddedBundleVersion(request)) .setExtensionName(SURFACES_EXTENSION_NAME) .setHostBundleId(request.getPackageName()) .setAppGroupId(surfacesAppGroup) 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 a8d4593354e..ac61e51f8cd 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 @@ -174,6 +174,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; @@ -405,8 +439,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 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 index 34d3e7b960d..4898e6f68a8 100644 --- 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 @@ -62,6 +62,44 @@ 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. From f1282846691c67144368ffee9c089b849a920a60 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:18:03 +0300 Subject: [PATCH 21/96] Name the version helpers as the daemon already names them The daemon has carried embeddedExtensionShortVersion and embeddedExtensionBundleVersion since the Matter extension needed them, and the companion copy of this file arrived at the same two methods under different names. Same name, same file, so the twins stay diffable. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 8bd2193cc60..b7f922d9d56 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 @@ -6016,8 +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 extShort = embeddedShortVersion(request); - String extBundle = embeddedBundleVersion(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. @@ -6702,7 +6702,7 @@ private void parseSurfacesManifest(File resDir, BuildRequest request) throws Bui * @param request the build being generated * @return the CFBundleShortVersionString the app itself will declare */ - private static String embeddedShortVersion(BuildRequest request) { + private static String embeddedExtensionShortVersion(BuildRequest request) { String injected = WatchNativeBuilder.injectedPlistString(request, "CFBundleShortVersionString"); return injected != null ? injected : WatchNativeBuilder.shortVersion(request); @@ -6712,7 +6712,7 @@ private static String embeddedShortVersion(BuildRequest request) { * The build version an embedded bundle must declare to match this app. * *

The fallback is {@code shortVersion(request)} and deliberately NOT - * {@link #embeddedShortVersion}: the two keys are independent, the app's CFBundleVersion is + * {@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.

@@ -6720,7 +6720,7 @@ private static String embeddedShortVersion(BuildRequest request) { * @param request the build being generated * @return the CFBundleVersion the app itself will declare */ - private static String embeddedBundleVersion(BuildRequest request) { + private static String embeddedExtensionBundleVersion(BuildRequest request) { String injected = WatchNativeBuilder.injectedPlistString(request, "CFBundleVersion"); return injected != null ? injected : request.getArg("ios.bundleVersion", WatchNativeBuilder.shortVersion(request)); @@ -6732,8 +6732,8 @@ private void writeWatchWidgetExtension(BuildRequest request, File distDir, File return; } IOSWidgetExtensionBuilder watchBuilder = new IOSWidgetExtensionBuilder() - .setVersions(embeddedShortVersion(request), - embeddedBundleVersion(request)) + .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 @@ -6775,8 +6775,8 @@ private void writeWatchWidgetExtension(BuildRequest request, File distDir, File */ private void appendWidgetExtensionTargets(StringBuilder sb, BuildRequest request, File distDir) throws IOException { IOSWidgetExtensionBuilder widgetBuilder = new IOSWidgetExtensionBuilder() - .setVersions(embeddedShortVersion(request), - embeddedBundleVersion(request)) + .setVersions(embeddedExtensionShortVersion(request), + embeddedExtensionBundleVersion(request)) .setExtensionName(SURFACES_EXTENSION_NAME) .setHostBundleId(request.getPackageName()) .setAppGroupId(surfacesAppGroup) From 023172eff078338f25645f2a765a3445d92fc35c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:23:13 +0300 Subject: [PATCH 22/96] Close the door the exported Tile trampoline opened Exporting CN1SurfaceActionActivity is what makes a Tile tap work at all -- the tile host starts it from its own process -- and it also lets any app on the watch start it with extras of its choosing, which the previous commit narrowed to Tile builds but did not otherwise defend. It dispatched whatever action id it was handed. Every surface this app draws now carries a per-install secret with the tap, and the trampoline dispatches only when the secret matches. The value is generated on first use, kept in the app's own private preferences, and travels only inside the layout handed to the tile host, which no other app can read. All three producers attach it through one method rather than each remembering to. The check applies exactly where it can matter: while the trampoline is private, nothing outside the app can start it and the token would only break a PendingIntent handed to the launcher before an update. Whether it is exported is read from the merged manifest rather than assumed, so the check follows what was actually declared. Two more from the same round, both consequences of generating the Wear manifest and gradle independently rather than merging them: The API 26 floor now rises only when the surface libraries that need it are present. A companion watch app that uses only 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-25 watches away from projects that declared no surface. android.xapplication_attr and android.xapplication are carried into the Wear manifest. A project that names a custom Application -- the usual way to initialise a native SDK -- got the stock one on the watch while the Wear module compiled the very sources that expect it. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/CN1SurfaceActionActivity.java | 82 ++++++++++++++++++- .../android/surfaces/CN1SurfaceRenderer.java | 2 + .../builders/AndroidGradleBuilder.java | 22 ++++- .../surfaces/wear/CN1SurfaceTileService.java | 6 ++ .../builders/WearGlueCompilesTest.java | 3 + 5 files changed, 112 insertions(+), 3 deletions(-) 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..35d94378727 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,7 +45,62 @@ 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) { @@ -49,7 +109,7 @@ protected void onCreate(Bundle savedInstanceState) { Intent intent = getIntent(); if (intent != null) { String actionId = intent.getStringExtra(EXTRA_ACTION_ID); - if (actionId != null) { + if (actionId != null && trusted(intent)) { AndroidSurfaceBridge.postAction(intent.getStringExtra(EXTRA_SOURCE), actionId, intent.getStringExtra(EXTRA_ACTION_PARAMS)); } @@ -61,6 +121,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/CN1SurfaceRenderer.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java index 58d2ef43bbe..b62f643a196 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java @@ -784,6 +784,7 @@ 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) { @@ -804,6 +805,7 @@ private static void applyAction(RemoteViews rv, JSONObject action, RenderContext 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) { 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 da2398e4111..8ee7ea4343d 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 @@ -7426,8 +7426,19 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str + " \n" + sharedPermissions + // 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. + " \n" + + " android:icon=\"@drawable/icon\"\n" + + " " + request.getArg("android.xapplication_attr", "") + ">\n" + + " " + 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. + " 0) { + gradle = gradle.replaceFirst("minSdkVersion \\d+", "minSdkVersion 26"); + } return gradle; } 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 index 0866fba2b35..8d0532ac1df 100644 --- 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 @@ -377,6 +377,12 @@ private androidx.wear.protolayout.ActionBuilders.LaunchAction launchAction(JSONO new androidx.wear.protolayout.ActionBuilders.AndroidActivity.Builder() .setPackageName(getPackageName()) .setClassName(CN1SurfaceActionActivity.class.getName()); + // The trampoline is exported so the tile host can start it, which means any app on the + // watch can too. This 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. + activity.addKeyToExtraMapping(CN1SurfaceActionActivity.EXTRA_TOKEN, + stringExtra(CN1SurfaceActionActivity.token(this))); activity.addKeyToExtraMapping(CN1SurfaceActionActivity.EXTRA_SOURCE, stringExtra(getKindId())); activity.addKeyToExtraMapping(CN1SurfaceActionActivity.EXTRA_ACTION_ID, 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 index 8a35b9755f6..13e88cb3d1e 100644 --- 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 @@ -133,6 +133,9 @@ void theInjectedWearServicesCompile(@TempDir Path tmp) throws IOException { + " 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); From a1551b3bacecf15dbc357ad897f7a1176b760a98 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:31:27 +0300 Subject: [PATCH 23/96] Round six review: findings raised against the daemon, fixed at the source Codex reviews both repositories, and these came in against the daemon's copies. They are the same code, so they are fixed here and mirrored. A hint could override the two values the Wear artifact cannot get wrong. android.xgradle_default_config lets a project add its own minSdkVersion and versionCode inside defaultConfig, and those land AFTER the generated ones -- so rewriting the generated declaration left the project's value effective: the watch quietly kept the phone's version code, or a floor below the one the Wear libraries need, while wearVersionCode validated a number nothing used. Both are now declared in a trailing block, which is evaluated last whatever the file above it says. The Wear launcher had no theme. The shared stub extends AppCompatActivity when android.extendAppCompatActivity is set, and AppCompat refuses to start under a theme that is not one of its own, so the watch crashed on its first frame. Both manifests now take the theme from one method -- which also corrects a typo it inherited: the AppCompat value was written "@@style/...", and a leading @@ in a resource attribute is the escape for a literal @, so it was a plain string naming nothing and the theme was never applied. That branch only runs when a project has asked for AppCompat, so the intent is not in doubt. Preview data ignored the type it was asked about. Wear takes it as an answer about that type, and a ShortText handed to a slot advertising LONG_TEXT or RANGED_VALUE is rejected or drawn empty -- so a kind went missing from exactly the pickers where its layout is roomiest. MONOCHROMATIC_IMAGE answers null rather than inventing an icon, since that type is nothing but its icon. A Tile showing a clock or a countdown froze for ever. Freshness came only from the next timeline flip, and a timeline with no future entry asked for no refresh at all, so "in 5 minutes" still said that the next day. Dynamic text now earns a bounded refresh of its own -- minute-accurate, which is what a Tile's rate limit allows anyway. An unpublish left the complication showing withdrawn content. The Data Layer announces it as a deletion, and a mirror never entered the replication cache that ordinarily handles one, so the tombstone went down a path that knew nothing about it and the descriptor stayed on disk. Mirror paths are now handled whether the item arrived or left. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceMirror.java | 42 +++++++++++++ .../builders/AndroidGradleBuilder.java | 61 +++++++++++++++---- .../wear/CN1ComplicationDataSource.java | 33 +++++++++- .../surfaces/wear/CN1SurfaceTileService.java | 31 ++++++++-- .../wearable/CN1WearableListenerService.java | 15 ++++- .../builders/WearModuleGradleTest.java | 47 ++++++++++++++ 6 files changed, 210 insertions(+), 19 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index f6f28ab0afe..af0c10636d0 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -237,6 +237,48 @@ public static void receiveFile(Context ctx, String path, byte[] payload) { } } + /** + * 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 + */ + public static void remove(Context ctx, String path) { + try { + String kindId = kindOf(path); + if (kindId == null) { + return; + } + // 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); + 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); + } catch (Throwable t) { + Log.w(TAG, "Could not withdraw a mirrored surface from " + path, t); + } + } + /** 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); 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 8ee7ea4343d..4a86ee61037 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 @@ -4805,10 +4805,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { 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" + " */ + /** + * 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"; + } + /** * Whether any declared kind earns a Tile, and so whether the tap trampoline has to be * reachable from outside this app. @@ -7443,7 +7463,12 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str // 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" @@ -7643,8 +7668,6 @@ private void copyMobileServiceConfig(File studioProjectDir, File wearDir) static String deriveWearGradle(String appGradle, int intVersion, int wearVersion, String wearDependencies) { String gradle = appGradle - // Same namespace and applicationId; see the method comment. - .replace("versionCode " + intVersion, "versionCode " + wearVersion) // Libraries are shared from the app module rather than copied. .replace("fileTree(dir: 'libs'", "fileTree(dir: '../app/libs'") .replace("dirs 'libs'", "dirs '../app/libs'") @@ -7676,15 +7699,31 @@ static String deriveWearGradle(String appGradle, int intVersion, int wearVersion // 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 unconditionally took API 23 to 25 + // 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) { - gradle = gradle.replaceFirst("minSdkVersion \\d+", "minSdkVersion 26"); + wins.append(" minSdkVersion 26\n"); } - return gradle; + wins.append(" }\n") + .append("}\n"); + return gradle + wins; } /** 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 index a3a7850aa2b..70bafd9b822 100644 --- 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 @@ -107,7 +107,38 @@ public ComplicationData getPreviewData(ComplicationType type) { // 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. - return shortText(getKindId(), null, null, null); + // + // 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; + } + return shortText(shorten(label), null, null, null); } /** 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 index 8d0532ac1df..1dc42b20ba0 100644 --- 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 @@ -77,6 +77,8 @@ public abstract class CN1SurfaceTileService extends TileService { /** 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 widget kind this Tile serves. Supplied by the generated subclass. */ protected abstract String getKindId(); @@ -121,7 +123,8 @@ private TileBuilders.Tile buildTile() { root = text("No data yet"); } else { root = render(reading.getLayout(), reading.getState(), 0, false); - freshness = freshnessFor(reading.getNextFlipDate()); + freshness = freshnessFor(reading.getNextFlipDate(), + hasDynamicText(reading.getLayout())); version = resourcesVersion(reading); } } catch (Throwable t) { @@ -151,17 +154,37 @@ private TileBuilders.Tile buildTile() { * 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.

*/ - private static long freshnessFor(long nextFlipDate) { - if (nextFlipDate <= 0) { + 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; } - long delta = nextFlipDate - System.currentTimeMillis(); + 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. + private static boolean hasDynamicText(JSONObject root) { + for (JSONObject node : CN1WatchSurface.flatten(root)) { + if ("dyn".equals(node.optString("t", ""))) { + return true; + } + } + return false; + } + /** * The version the Tile advertises for its resource set. * 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 c5cbf3f5c98..3ea88ff9c07 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 @@ -449,10 +449,19 @@ && dataItemExists(uri)) { // 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 && !deleted + if (appPath != null && com.codename1.impl.android.surfaces.CN1SurfaceMirror.isMirrorPath(appPath)) { - com.codename1.impl.android.surfaces.CN1SurfaceMirror.receive(this, appPath, - readMirrorPayload(event)); + // 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. + if (deleted) { + com.codename1.impl.android.surfaces.CN1SurfaceMirror.remove(this, appPath); + } else { + com.codename1.impl.android.surfaces.CN1SurfaceMirror.receive(this, appPath, + readMirrorPayload(event)); + } continue; } // Read before anything is cleared, so the reset below can tell this device's own 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 index 8814e082e66..0fe86a90a63 100644 --- 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 @@ -131,6 +131,53 @@ void theWearDependencyIsInsertedExactlyOnce() { "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); + } + /// 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 From a51ab65d6da818ec5741761a85640235d9e51b36 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:31:45 +0300 Subject: [PATCH 24/96] Round seven review: answer the whole timeline, and four more setValidTimeRange was the wrong mechanism and the review was right to say so: it governs when a value may be DISPLAYED and schedules no further request, so on its own it replaced a stale complication with an empty one rather than with the next entry. androidx has the mechanism this feature was shaped for -- ComplicationDataTimeline, delivered through onComplicationDataTimeline -- and the service now hands over every published entry at once. The system swaps them at the stated moments without waking this process, which is the same bargain WidgetKit makes on the other platform. CN1WatchSurface grew readTimeline for it, because the reader had been collapsing the document to whichever entry was current. The Wear manifest declares xmlns:tools. Copying the application hints into it was the previous commit's fix, and tools:replace and tools:node are how a project resolves a merger conflict -- so a root declaring only xmlns:android turned the copy into a document that would not parse. READ_MEDIA_* declarations reach the Wear manifest. It is generated independently, so a watchMain reading media on Wear OS 4 could never be granted the runtime permission the phone half asks for. A late image transfer no longer resurrects a collected blob. Transfers are asynchronous and unordered, so art belonging to publication N can arrive after the descriptor for N+1 has already collected it; the write is now conditional on the descriptor currently on disk still naming it. A kind with no descriptor yet still accepts artwork, because it legitimately arrives first in that case. A mirrored kind is remembered. It was never published by the watch process, so nothing recorded it, and reloadWidgets(null) walks the remembered set -- a reload-all on a watch whose content only ever came from the phone skipped the complication entirely. Proguard is read from the app module, which is the keystore trap one line further on: the generated proguard.cfg is written only into app/, 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. The injected services were typechecked against the real androidx.wear jars again, and the stub tree corrected where it had been looser than the API. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceMirror.java | 51 ++++++++ .../android/surfaces/CN1WatchSurface.java | 75 +++++++++++ .../builders/AndroidGradleBuilder.java | 23 +++- .../wear/CN1ComplicationDataSource.java | 122 ++++++++++-------- .../builders/WearModuleGradleTest.java | 19 +++ .../builders/WearableGlueCompilesTest.java | 3 + .../ComplicationDataSourceService.javas | 7 +- .../datasource/ComplicationDataTimeline.javas | 6 + .../datasource/TimeInterval.javas | 4 + .../datasource/TimelineEntry.javas | 5 + .../org/json/JSONObject.javas | 1 + 11 files changed, 259 insertions(+), 57 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataTimeline.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimeInterval.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimelineEntry.javas diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index af0c10636d0..802dbaed738 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -188,6 +188,10 @@ public static void receive(Context ctx, String path, byte[] payload) { // 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. CN1SurfaceStore.deleteUnreferencedImages(kindDir, new String(json, "UTF-8")); + // 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); } catch (Throwable t) { Log.w(TAG, "Could not apply a mirrored surface from " + path, t); @@ -224,6 +228,17 @@ public static void receiveFile(Context ctx, String path, byte[] payload) { return; } File dir = CN1SurfaceStore.kindDir(ctx, kindId); + // Only if the descriptor currently on disk still names it. File transfers are + // asynchronous and unordered, so an image belonging to publication N can arrive after + // the descriptor for N+1 has already collected it -- and writing it back unconditionally + // resurrected a blob nothing references, with no later collection guaranteed to + // remove it. Storage would grow for ever under exactly the rapid republishing the + // ordering hazard needs. + if (!referencedByCurrentTimeline(ctx, kindId, name)) { + Log.w(TAG, "Ignoring a mirrored image no longer referenced by " + kindId + ": " + + name); + return; + } mkdirs(dir); writeAtomically(new File(dir, name), contents); // A file transfer is asynchronous and unordered against the descriptor, so artwork @@ -237,6 +252,42 @@ public static void receiveFile(Context ctx, String path, byte[] payload) { } } + /** + * Whether the descriptor currently on disk still names an image. + * + *

Answered from the stored timeline rather than from the transfer, because the transfer is + * the thing that may be stale. A kind with no descriptor yet answers true: the artwork + * legitimately arrives first in that case, and the descriptor's own collection will remove it + * a moment later if it turns out to be unwanted.

+ * + * @param ctx any context + * @param kindId the widget kind + * @param name the blob name, without its extension + * @return true when the image should be written + */ + private static boolean referencedByCurrentTimeline(Context ctx, String kindId, String name) { + String json = CN1SurfaceStore.readWidgetTimeline(ctx, kindId); + if (json == null || json.length() == 0) { + return true; + } + try { + org.json.JSONArray images = new org.json.JSONObject(json).optJSONArray("images"); + if (images == null) { + return true; + } + String bare = name.endsWith(".png") ? name.substring(0, name.length() - 4) : name; + for (int i = 0; i < images.length(); i++) { + if (bare.equals(images.optString(i))) { + return true; + } + } + return false; + } catch (Throwable t) { + // An unreadable descriptor is not evidence against the image. + return true; + } + } + /** * Withdraws a mirrored surface the phone has removed. * diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java index 0f90d4ece2d..7c7bd542fa3 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java @@ -68,10 +68,29 @@ public static final class Reading { private final JSONObject state; private final long nextFlipDate; + private final long start; + Reading(JSONObject layout, JSONObject state, long nextFlipDate) { + this(layout, state, nextFlipDate, 0L); + } + + Reading(JSONObject layout, JSONObject state, long nextFlipDate, long start) { this.layout = layout; this.state = state; this.nextFlipDate = nextFlipDate; + this.start = start; + } + + /** + * 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() { @@ -128,6 +147,62 @@ public static Reading read(Context ctx, String kindId, String family) { } } + /** + * 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(); + 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)); + } + 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)); + } + } 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. * 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 4a86ee61037..121b1766304 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 @@ -326,6 +326,8 @@ public File getGradleProjectDirectory() { private String watchSurfaceDependencies = ""; /// The generated phone stub's source, so the Wear module can derive its own from it. private String generatedStubSource; + /** READ_MEDIA_* declarations the Wear manifest needs too; see the phone manifest. */ + private String watchReadMediaPermissions = ""; /// 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. @@ -4693,6 +4695,11 @@ && 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. + watchReadMediaPermissions = readMediaPermissions; String xmlizedDisplayName = xmlize(request.getDisplayName()); String applicationAttr = request.getArg("android.xapplication_attr", ""); @@ -6688,7 +6695,8 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } generateWearModule(request, studioProjectDir, gradleProps, watchSurfacesManifestEntries, - basePermissions + permissions + xPermissions, intVersion, wearableListenerService); + basePermissions + permissions + xPermissions + watchReadMediaPermissions, + intVersion, wearableListenerService); String rootGradleProps = "// Top-level build file where you can add configuration options common to all sub-projects/modules.\n" + "buildscript {\n" + @@ -7442,6 +7450,11 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str String wearManifest = "\n" + "\n" + " \n" @@ -7676,7 +7689,13 @@ static String deriveWearGradle(String appGradle, int intVersion, int wearVersion // 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\")"); + .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 -- 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 index 70bafd9b822..f0507a21c5d 100644 --- 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 @@ -39,13 +39,16 @@ 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.TimeRange; 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; /** @@ -81,23 +84,78 @@ public abstract class CN1ComplicationDataSource extends ComplicationDataSourceSe @Override public void onComplicationRequest(ComplicationRequest request, ComplicationRequestListener listener) { + ComplicationType type = request.getComplicationType(); + ComplicationDataTimeline timeline = null; ComplicationData data = null; try { - data = build(request.getComplicationType()); + 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; + } + ComplicationData current = build(type, readings.get(0)); + if (current == null) { + return null; + } + List entries = new ArrayList(); + for (int i = 1; i < readings.size(); i++) { + CN1WatchSurface.Reading reading = readings.get(i); + ComplicationData entry = build(type, reading); + if (entry == null) { + // A later entry this type cannot render is skipped rather than ending the + // timeline: the entries after it may well be renderable, and the face falls back + // to the default in the gap. + continue; + } + long end = reading.getNextFlipDate(); + entries.add(new TimelineEntry( + new TimeInterval(Instant.ofEpochMilli(reading.getStart()), + end > reading.getStart() ? Instant.ofEpochMilli(end) : Instant.MAX), + entry)); + } + return new ComplicationDataTimeline(current, entries); + } + @Override public ComplicationData getPreviewData(ComplicationType type) { try { - ComplicationData data = build(type); + ComplicationData data = build(type, null); if (data != null) { return data; } @@ -138,7 +196,7 @@ private ComplicationData placeholder(ComplicationType type) { // no honest placeholder to give. Null lets the picker fall back to another type. return null; } - return shortText(shorten(label), null, null, null); + return shortText(shorten(label), null, null); } /** @@ -158,8 +216,9 @@ private static String familyFor(ComplicationType type) { return "watchCircular"; } - private ComplicationData build(ComplicationType type) { - CN1WatchSurface.Reading reading = CN1WatchSurface.read(this, getKindId(), familyFor(type)); + private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading given) { + CN1WatchSurface.Reading reading = given != null ? given + : CN1WatchSurface.read(this, getKindId(), familyFor(type)); if (reading == null) { return null; } @@ -167,14 +226,6 @@ private ComplicationData build(ComplicationType type) { List texts = CN1WatchSurface.texts(nodes, reading.getState()); PendingIntent tap = tapIntent(reading.getLayout()); reportDroppedContent(nodes, texts); - // How long this answer is good for. A published timeline can hold entries that take over - // at stated times, and this service is asked once and then not again: UPDATE_PERIOD_SECONDS - // is deliberately 0, because polling a push-driven surface costs watch battery for nothing. - // Without a validity the face would keep the first entry for ever and the later ones would - // never appear. Saying when the data stops being true asks the system to come back at that - // moment and nowhere in between, which is the same bargain the Tile's freshness interval - // makes. - TimeRange validity = validityFor(reading.getNextFlipDate()); if (ComplicationType.LONG_TEXT.equals(type)) { String title = texts.isEmpty() ? getKindId() : texts.get(0); @@ -184,9 +235,6 @@ private ComplicationData build(ComplicationType type) { plain(title)) .setTitle(body.length() == 0 ? null : plain(title)) .setTapAction(tap); - if (validity != null) { - builder.setValidTimeRange(validity); - } return builder.build(); } if (ComplicationType.RANGED_VALUE.equals(type)) { @@ -204,9 +252,6 @@ private ComplicationData build(ComplicationType type) { builder.setText(plain(shorten(texts.get(0)))); } builder.setTapAction(tap); - if (validity != null) { - builder.setValidTimeRange(validity); - } return builder.build(); } if (ComplicationType.MONOCHROMATIC_IMAGE.equals(type)) { @@ -219,9 +264,6 @@ private ComplicationData build(ComplicationType type) { new MonochromaticImage.Builder(icon).build(), plain(texts.isEmpty() ? getKindId() : texts.get(0))) .setTapAction(tap); - if (validity != null) { - builder.setValidTimeRange(validity); - } return builder.build(); } if (ComplicationType.SHORT_TEXT.equals(type)) { @@ -229,38 +271,13 @@ private ComplicationData build(ComplicationType type) { return null; } return shortText(shorten(texts.get(0)), texts.size() > 1 ? shorten(texts.get(1)) : null, - tap, validity); + tap); } return null; } - /** - * The window this answer stays true for, or null when it is good indefinitely. - * - *

A flip date in the past or absent means the timeline has one entry and nothing is - * scheduled to replace it, and claiming an expiry then would make the face throw away good - * data and ask again for the same answer.

- * - * @param flip the next entry's start time, in epoch millis, or 0 when there is none - * @return the validity window, or null - */ - private TimeRange validityFor(long flip) { - long now = System.currentTimeMillis(); - if (flip <= now) { - return null; - } - try { - return TimeRange.between(Instant.ofEpochMilli(now), Instant.ofEpochMilli(flip)); - } catch (Throwable t) { - // Never at the cost of the reading itself: data with no expiry is stale later, - // data that failed to build is absent now. - Log.w(TAG, "Could not bound the complication's validity for kind " + getKindId(), t); - return null; - } - } - - private ShortTextComplicationData shortText(String text, String title, PendingIntent tap, - TimeRange validity) { + private ShortTextComplicationData shortText(String text, String title, + PendingIntent tap) { // The untruncated string becomes the content description, so a screen reader still hears // what the layout said even where the slot shows seven characters. ShortTextComplicationData.Builder builder = @@ -269,9 +286,6 @@ private ShortTextComplicationData shortText(String text, String title, PendingIn builder.setTitle(plain(title)); } builder.setTapAction(tap); - if (validity != null) { - builder.setValidTimeRange(validity); - } return builder.build(); } 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 index 0fe86a90a63..615a42c2758 100644 --- 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 @@ -57,6 +57,12 @@ class WearModuleGradleTest { + " 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" @@ -178,6 +184,19 @@ void aWatchWithNoSurfacesKeepsTheWearOsTwoBaseline() { "the version code still has to outrank the phone:\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 diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java index 65033eb8ea6..efbd656ff95 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java @@ -110,6 +110,9 @@ void theInjectedWearableGlueCompiles(@TempDir Path tmp) throws IOException { + "public class CN1SurfaceStore {\n" + " public static File kindDir(Context c, String k) { return null; }\n" + " static void deleteUnreferencedImages(File d, String t) { }\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" 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 index 05885c2c234..b21c6292413 100644 --- 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 @@ -3,7 +3,12 @@ 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); } + 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/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/org/json/JSONObject.javas b/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONObject.javas index 87d1ff873c1..71d3194a950 100644 --- 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 @@ -8,6 +8,7 @@ public class JSONObject { 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; } From a270b7b3accab52ff1c956b22ce81c814aec7134 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:34:59 +0300 Subject: [PATCH 25/96] Recognize the debug Wear artifact's role suffix A signed request may ask for release and debug together, and the phone half returns both -- so the companion build now returns a Wear artifact per variant, named the way the phone names its own: MyApp-wear.apk beside MyApp-wear-debug .apk. Result extraction has to know the longer suffix or the debug artifact lands on the phone APK's path. Longest first, because a suffix that is a tail of another would otherwise match the shorter one. Neither of these two is, but the next one might be. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/main/java/com/codename1/maven/CN1BuildMojo.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 600857a686c..a1192ad68b3 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 @@ -2480,7 +2480,9 @@ protected void afterBuild() { * keeps the plain {@code } name it has always had, so adding a * role here is the only way to change where a file lands.

*/ - private static final String[] ARTIFACT_ROLE_SUFFIXES = {"-wear"}; + // 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. + 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 From ccccac37579ae4124416f84a3eb966207b4df40d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:14:41 +0300 Subject: [PATCH 26/96] Take the conditional image write back out Refusing a mirrored image the stored descriptor does not name was wrong, and the review caught it: onPublished sends the images BEFORE the descriptor that names them -- deliberately, 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 on disk. The check rejected exactly the art the next descriptor was waiting for, the transfer was then acknowledged and gone, and the new descriptor referenced a blob that would never exist. Trading a gap for a broken image is not a trade. The hazard it was meant to close is real but much smaller than it looked, and that reasoning is now recorded where the next reader will find it: every descriptor collects what it does not reference, so art orphaned by a later publication is removed by the next one, and only the art in flight during the last publish of all can linger. A fixed cost, not unbounded growth. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceMirror.java | 62 +++++-------------- 1 file changed, 15 insertions(+), 47 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index 802dbaed738..6fac679b492 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -228,17 +228,21 @@ public static void receiveFile(Context ctx, String path, byte[] payload) { return; } File dir = CN1SurfaceStore.kindDir(ctx, kindId); - // Only if the descriptor currently on disk still names it. File transfers are - // asynchronous and unordered, so an image belonging to publication N can arrive after - // the descriptor for N+1 has already collected it -- and writing it back unconditionally - // resurrected a blob nothing references, with no later collection guaranteed to - // remove it. Storage would grow for ever under exactly the rapid republishing the - // ordering hazard needs. - if (!referencedByCurrentTimeline(ctx, kindId, name)) { - Log.w(TAG, "Ignoring a mirrored image no longer referenced by " + kindId + ": " - + name); - return; - } + // 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 -- art from publication N arriving after N+1's descriptor has + // already collected -- leaves an orphan, but a bounded one: every descriptor collects + // what it does not reference, so the next publish removes it, and only the art in + // flight during the last publish of all can linger. That is a fixed cost, not the + // unbounded growth it looks like at first glance, and it is the cheaper of the two + // failures by a wide margin. mkdirs(dir); writeAtomically(new File(dir, name), contents); // A file transfer is asynchronous and unordered against the descriptor, so artwork @@ -252,42 +256,6 @@ public static void receiveFile(Context ctx, String path, byte[] payload) { } } - /** - * Whether the descriptor currently on disk still names an image. - * - *

Answered from the stored timeline rather than from the transfer, because the transfer is - * the thing that may be stale. A kind with no descriptor yet answers true: the artwork - * legitimately arrives first in that case, and the descriptor's own collection will remove it - * a moment later if it turns out to be unwanted.

- * - * @param ctx any context - * @param kindId the widget kind - * @param name the blob name, without its extension - * @return true when the image should be written - */ - private static boolean referencedByCurrentTimeline(Context ctx, String kindId, String name) { - String json = CN1SurfaceStore.readWidgetTimeline(ctx, kindId); - if (json == null || json.length() == 0) { - return true; - } - try { - org.json.JSONArray images = new org.json.JSONObject(json).optJSONArray("images"); - if (images == null) { - return true; - } - String bare = name.endsWith(".png") ? name.substring(0, name.length() - 4) : name; - for (int i = 0; i < images.length(); i++) { - if (bare.equals(images.optString(i))) { - return true; - } - } - return false; - } catch (Throwable t) { - // An unreadable descriptor is not evidence against the image. - return true; - } - } - /** * Withdraws a mirrored surface the phone has removed. * From f655db048036ca8f671a38c095754fc6355665d1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:18:39 +0300 Subject: [PATCH 27/96] Round nine review: exact family names, and two the code answers already A mistyped family name is refused instead of silently doing nothing. isWatch tested for a "watch" prefix, so "watchCircle" counted as a watch family here while every mapping downstream -- the layout picker, the complication types, the Tile decision -- recognises only the real four. The kind lost its phone widget, gained watch codegen, and ended up with no usable surface on any platform, in a build that went green. isWatch is now exact, and the builder names the offending family and lists the real ones rather than treating a typo as a phone family and rendering a home-screen widget nobody asked for. Two findings I do not think are bugs, argued in the code where the next reader will meet them rather than in a thread they will not: The Tile's inline images stay PNG with IMAGE_FORMAT_UNDEFINED. The library documents that pairing: of the format it says 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". The named formats describe a raw pixel buffer; a PNG carries its own header. SHORT_TEXT still asks for the inline family first. The name is the first thing pickLayout tries, and when the document has no layout under it the picker falls back through the kind's other watch layouts -- and only declared families are in the document at all, so the fallback lands on one of the kind's own. Choosing here would move the decision somewhere that cannot see the document. And the advertised complication types stay derived from the declared family: whether a layout will hold a progress node or an image is a property of what the app publishes at runtime and can change between publishes, so narrowing at build time would guess about a document that does not exist yet, and guessing low makes a kind unselectable in a slot it will later fill. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 26 +++++++++++ .../codename1/util/SurfaceKindFamilies.java | 45 ++++++++++++++++++- .../wear/CN1ComplicationDataSource.java | 8 ++++ .../surfaces/wear/CN1SurfaceTileService.java | 9 ++++ .../util/SurfaceKindFamiliesTest.java | 31 +++++++++++++ 5 files changed, 118 insertions(+), 1 deletion(-) 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 121b1766304..bbb1db41f6c 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 @@ -3497,6 +3497,23 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } 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) { @@ -7794,6 +7811,15 @@ static String joinFamilies(List families) { * @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(",")) { 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 index b16b41943ad..960cedec5ad 100644 --- 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 @@ -113,14 +113,57 @@ public static String normalize(String family) { 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 && normalize(family).startsWith("watch"); + 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); } /** 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 index f0507a21c5d..ebb9555c06c 100644 --- 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 @@ -206,6 +206,14 @@ private ComplicationData placeholder(ComplicationType type) { * 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"; 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 index 1dc42b20ba0..3c2bf32bdf8 100644 --- 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 @@ -222,6 +222,15 @@ private ResourceBuilders.Resources buildResources() { 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); builder.addIdToImageMapping(e.getKey(), 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 index e377850699f..45d4783f86f 100644 --- 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 @@ -130,6 +130,37 @@ void emptyDeclarationIsAPhoneKind() { 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()); From 7333c48174bafa2b6f39f6a001550e636a4f2b2f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:58:38 +0300 Subject: [PATCH 28/96] Round ten review: an untrusted tap does nothing at all The token check stopped a forged action from dispatching and then fell through to launchMainActivity anyway, so an app that could not forge anything could still start the exported trampoline in a loop and foreground this application over and over -- a nuisance the user would blame on us. An intent that fails the check now ends the activity and returns, and the check runs before the action is read so an intent carrying no action id is treated the same way. The iOS parser refuses an unknown family name too, and there it prevents a worse failure than on Android: 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 and falls back to all three home-screen sizes. A typo SHIPS three widgets the manifest never asked for rather than shipping none. The Wear module reads AIDL from the same tree as the Java. 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 the wear module at a src/main/aidl that no build creates left it compiling the billing sources with no IInAppBillingService to compile against. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/CN1SurfaceActionActivity.java | 11 ++++++++++- .../codename1/builders/AndroidGradleBuilder.java | 7 ++++++- .../com/codename1/builders/IPhoneBuilder.java | 15 +++++++++++++++ .../codename1/builders/WearModuleGradleTest.java | 13 +++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) 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 35d94378727..4b00a73bf70 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceActionActivity.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceActionActivity.java @@ -107,9 +107,18 @@ 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 && trusted(intent)) { + if (actionId != null) { AndroidSurfaceBridge.postAction(intent.getStringExtra(EXTRA_SOURCE), actionId, intent.getStringExtra(EXTRA_ACTION_PARAMS)); } 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 bbb1db41f6c..04b1e70b59f 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 @@ -7722,7 +7722,12 @@ static String deriveWearGradle(String appGradle, int intVersion, int wearVersion + " java.srcDirs = ['../app/src/main/java', 'src/main/java']\n" + " res.srcDirs = ['../app/src/main/res', 'src/main/res']\n" + " assets.srcDirs = ['../app/src/main/assets']\n" - + " aidl.srcDirs = ['../app/src/main/aidl']\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 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 b7f922d9d56..21853fc63d9 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 @@ -6576,6 +6576,21 @@ private void parseSurfacesManifest(File resDir, BuildRequest request) throws Bui // 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); } 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 index 615a42c2758..2cac0e433dc 100644 --- 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 @@ -184,6 +184,19 @@ void aWatchWithNoSurfacesKeepsTheWearOsTwoBaseline() { "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, From 291429ae0763d0ace45a3255a3e01057b8f8ec50 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:39:27 +0300 Subject: [PATCH 29/96] Round eleven review: five, one of them my own regression The Wear application tag stopped being valid XML. Copying android.xapplication_attr in verbatim while also emitting android:label and android:icon produced each attribute twice when the hint set one, and a duplicate attribute is not a merge conflict but a document that does not parse -- so a companion project with a custom label or icon failed before packaging, taking the whole build rather than the watch half. The tag is now built the way the phone manifest builds its own: a default only where the hint has not already said it. A reload never reached the watch. reloadWidgets means "draw what you already hold again", which on the watch is watch-local -- 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 found nothing. The phone cannot reach into that process, so it asks: the stored descriptor is re-sent over the mirror, the watch applies it as it applies any other, and its notifier runs where the generated services actually are. The nonce is load-bearing -- the Data Layer suppresses an unchanged item, which is what a publish wants and what a reload has to defeat. Rows and columns space their children. setSpacing serializes as "spacing" and the Tile added children straight onto the builder, so a descriptor that spaces correctly in the simulator, in WidgetKit and in an Android widget packed together here. ProtoLayout containers have no spacing property, so the gap is an explicit element sized along the container's axis. Linear progress renders as a bar. Reaching for an Arc unconditionally turned every default SurfaceProgress into a ring -- a different shape from the one the same descriptor draws everywhere else. The arc is now reserved for the circular style it was added for. WRITE_EXTERNAL_STORAGE reaches the Wear manifest, so a watch lifecycle is not refused on API 26 to 29 an operation the phone artifact is permitted. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/AndroidSurfaceBridge.java | 6 ++ .../android/surfaces/CN1SurfaceMirror.java | 46 ++++++++++++ .../builders/AndroidGradleBuilder.java | 48 ++++++++++-- .../surfaces/wear/CN1SurfaceTileService.java | 73 ++++++++++++++++++- .../wear/protolayout/DimensionBuilders.javas | 7 +- .../protolayout/LayoutElementBuilders.javas | 2 + 6 files changed, 168 insertions(+), 14 deletions(-) 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 7cc140cf9d1..dc3527bbbde 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java @@ -133,11 +133,17 @@ public void reloadWidgets(String kindId) { // 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); } } diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index 6fac679b492..6fb32c7ef58 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -124,6 +124,52 @@ public static void onPublished(Context ctx, String kindId, String timelineJson, } } + /** + * 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. No images: their names are content hashes, so whatever the descriptor + * references is already beside it.

+ * + * @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; + } + 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); + } + } + private static void sendImages(String kindId, Map images) { if (images == null || images.isEmpty()) { return; 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 04b1e70b59f..97a7907628b 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 @@ -326,8 +326,13 @@ public File getGradleProjectDirectory() { private String watchSurfaceDependencies = ""; /// The generated phone stub's source, so the Wear module can derive its own from it. private String generatedStubSource; - /** READ_MEDIA_* declarations the Wear manifest needs too; see the phone manifest. */ - private String watchReadMediaPermissions = ""; + /** + * 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 = ""; /// 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. @@ -4715,8 +4720,10 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { // 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. - watchReadMediaPermissions = readMediaPermissions; + // 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", ""); @@ -6712,7 +6719,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } generateWearModule(request, studioProjectDir, gradleProps, watchSurfacesManifestEntries, - basePermissions + permissions + xPermissions + watchReadMediaPermissions, + 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" + @@ -7238,6 +7245,33 @@ private String launcherTheme() { ? "@style/Theme.AppCompat.NoActionBar" : "@style/CustomTheme"; } + /** + * 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(); + } + /** * Whether any declared kind earns a Tile, and so whether the tap trampoline has to be * reachable from outside this app. @@ -7485,9 +7519,7 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str // 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. - + " \n" + + 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. 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 index 3c2bf32bdf8..c4537d808bc 100644 --- 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 @@ -261,14 +261,26 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s 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(render(child, state, depth + 1, false)); } return col.setModifiers(modifiers(node)).build(); } 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(render(child, state, depth + 1, true)); } return row.setModifiers(modifiers(node)).build(); @@ -310,13 +322,19 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s .build(); } if ("prog".equals(type)) { - // A ProtoLayout arc renders the ring natively -- the one place a Tile beats the phone - // widget, which has to degrade a circular bar to a linear one. 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. + if (!"circular".equals(node.optString("style", "linear"))) { + return linearProgress(fraction); + } return new LayoutElementBuilders.Arc.Builder() .addContent(new LayoutElementBuilders.ArcLine.Builder() - .setLength(DimensionBuilders.degrees( - 360f * (value < 0 ? 0f : value))) + .setLength(DimensionBuilders.degrees(360f * fraction)) .setThickness(DimensionBuilders.dp(6)) .build()) .build(); @@ -326,6 +344,53 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s return styledText(node, state); } + /// 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. + private static LayoutElementBuilders.LayoutElement linearProgress(float fraction) { + 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(0xFFFFFFFF)) + .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(0x40FFFFFF)) + .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 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 index 0c9992b01f8..ce97847ac65 100644 --- 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 @@ -5,13 +5,16 @@ public final class DimensionBuilders { // 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 { } - public static class ExpandedDimensionProp implements SpacerDimension, 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 index a9a0a010a1b..35549bb6e73 100644 --- 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 @@ -36,6 +36,8 @@ public final class LayoutElementBuilders { 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 setModifiers(ModifiersBuilders.Modifiers m) { return this; } public Box build() { return null; } } From 1fcd13f3a2b11181e931187ec75bb976e3f7c8a1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:20:23 +0300 Subject: [PATCH 30/96] Round twelve review: an app called fitness-wear, and two more A role suffix is a claim about a set, not about a name. An app whose artifact is legitimately called "fitness-wear" returns one APK, and reading the suffix off its name alone made the primary artifact a companion: it was copied to -wear.apk, attached under a classifier, and the artifact the build was actually for was missing. The role is now decided from what else came back -- per extension, because a build can return a primary APK and no primary AAB. The Wear manifest declares the push service. The wear module compiles the same generated CN1FirebaseMessagingService, resolves the same Firebase dependencies and now carries the same google-services.json -- everything except the declaration that lets Play services bind it, so a push arriving on the watch had nothing to deliver to. A Tile progress node honours its own modifiers. Both branches returned the Arc or the bar directly, skipping modifiers(node) -- 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. Neither Arc nor Row has a setModifiers of its own, so the element is wrapped in a Box that does. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 12 +++++ .../com/codename1/maven/CN1BuildMojo.java | 45 ++++++++++++++++++- .../surfaces/wear/CN1SurfaceTileService.java | 23 +++++++--- .../maven/CN1BuildResultArtifactRoleTest.java | 32 +++++++++++++ 4 files changed, 104 insertions(+), 8 deletions(-) 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 97a7907628b..e34b2502fc0 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 @@ -333,6 +333,9 @@ public File getGradleProjectDirectory() { * 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 = ""; /// 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. @@ -4831,6 +4834,13 @@ && 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")) { @@ -7543,6 +7553,8 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str // 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 // A complication or Tile tap still needs the trampoline, and a TILE tap needs it // reachable from the tile host's process -- see anyWatchTile. + " -wear.apk and attached it under a classifier, leaving the + // artifact the build was for missing entirely. + java.util.Set extensionsWithPrimary = new java.util.HashSet(); + for (File child : resultFiles) { + String name = child.getName(); + int dot = name.lastIndexOf("."); + if (dot < 0) { + continue; + } + if (roleSuffixOf(name.substring(0, dot)).length() == 0) { + extensionsWithPrimary.add(name.substring(dot)); + } + } + for (File child : resultFiles) { String name = child.getName(); int dotpos = name.lastIndexOf("."); if (dotpos < 0) { @@ -1742,7 +1760,7 @@ private void createAntProject() throws IOException, LibraryPropertiesException, // 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 = roleSuffixOf(base); + String roleSuffix = roleSuffixFor(base, extension, extensionsWithPrimary); File copyTo = new File(project.getBuild().getDirectory() + File.separator + project.getBuild().getFinalName() + roleSuffix + extension); FileUtils.copyFile(child, copyTo); if (roleSuffix.length() > 0) { @@ -2482,6 +2500,29 @@ protected void afterBuild() { */ // 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. An app called + * {@code fitness-wear} returns one APK whose base ends in {@code -wear} and it is the primary + * artifact, not a companion -- reading the name alone copied it to + * {@code -wear.apk}, attached it under a classifier, and left the artifact the + * build was actually for missing. So a suffix only counts when something else of the same + * kind came back to be the primary one.

+ * + * @param base the entry's name with its extension removed + * @param extension the entry's extension, including the dot + * @param extensionsWithPrimary extensions for which an unsuffixed entry was returned + * @return the role suffix including its leading dash, or an empty string + */ + static String roleSuffixFor(String base, String extension, + java.util.Set extensionsWithPrimary) { + if (extensionsWithPrimary == null || !extensionsWithPrimary.contains(extension)) { + return ""; + } + return roleSuffixOf(base); + } + private static final String[] ARTIFACT_ROLE_SUFFIXES = {"-wear-debug", "-wear"}; /** 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 index c4537d808bc..309142f705c 100644 --- 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 @@ -329,14 +329,25 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s // 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. + LayoutElementBuilders.LayoutElement bar; if (!"circular".equals(node.optString("style", "linear"))) { - return linearProgress(fraction); + bar = linearProgress(fraction); + } else { + bar = new LayoutElementBuilders.Arc.Builder() + .addContent(new LayoutElementBuilders.ArcLine.Builder() + .setLength(DimensionBuilders.degrees(360f * fraction)) + .setThickness(DimensionBuilders.dp(6)) + .build()) + .build(); } - return new LayoutElementBuilders.Arc.Builder() - .addContent(new LayoutElementBuilders.ArcLine.Builder() - .setLength(DimensionBuilders.degrees(360f * fraction)) - .setThickness(DimensionBuilders.dp(6)) - .build()) + return new LayoutElementBuilders.Box.Builder() + .addContent(bar) + .setModifiers(modifiers(node)) .build(); } // text, dyn and anything unknown: whatever string the node resolves to. A dyn value is 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 index 298b75bdfab..1b6cf600b5b 100644 --- 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 @@ -50,4 +50,36 @@ void everythingElseIsThePrimaryArtifact() { assertEquals("", CN1BuildMojo.roleSuffixOf("")); assertEquals("", CN1BuildMojo.roleSuffixOf(null)); } + + /// 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.Set none = new java.util.HashSet(); + + assertEquals("", CN1BuildMojo.roleSuffixFor("fitness-wear", ".apk", none)); + assertEquals("", CN1BuildMojo.roleSuffixFor("fitness-wear-debug", ".apk", none)); + } + + /// ...and when the phone artifact did come back, the suffixed one is the companion. + @Test + void aSuffixedArtifactBesideAPrimaryOneIsTheCompanion() { + java.util.Set apk = new java.util.HashSet(); + apk.add(".apk"); + + assertEquals("-wear", CN1BuildMojo.roleSuffixFor("myapp-wear", ".apk", apk)); + assertEquals("-wear-debug", CN1BuildMojo.roleSuffixFor("myapp-wear-debug", ".apk", apk)); + assertEquals("", CN1BuildMojo.roleSuffixFor("myapp", ".apk", apk)); + } + + /// Per extension, because a build can return a primary APK and no primary AAB. + @Test + void theQuestionIsAskedPerExtension() { + java.util.Set apkOnly = new java.util.HashSet(); + apkOnly.add(".apk"); + + assertEquals("-wear", CN1BuildMojo.roleSuffixFor("myapp-wear", ".apk", apkOnly)); + assertEquals("", CN1BuildMojo.roleSuffixFor("myapp-wear", ".aab", apkOnly)); + } } From bcce5be3b29622763a8638d3ecf3eb4ac3197356 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:00:10 +0300 Subject: [PATCH 31/96] Round thirteen review: iOS reloads, Tile weights, and a prefix match reloadWidgets forwards to the watch on iOS too. surfacesReloadTimelines drives WidgetCenter, and a complication lives in another bundle on another device with its own copy of the descriptor -- so a reload redrew the phone's widget and left the watch showing what it had. The stored descriptor is handed over again, for one kind or for every kind that has published. No images: their names are content hashes, so whatever it references is already there. Tile rows and columns honour child weights. 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 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 element has no size to set afterwards, so the child goes inside a Box that carries it. An app group is matched as a whole token. The same trap the profile check already documents, one layer up: a project declaring group.com.example.shared contains the string group.com.example, so a substring test read the surfaces group as present and left the entitlement out -- and then the container does not resolve, areWidgetsSupported() answers false, and publish() returns before the bridge, taking the watch mirror with it in exactly the watch-only configuration this entitlement was widened for. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/impl/ios/IOSSurfaceBridge.java | 55 +++++++++++++++++++ .../com/codename1/builders/IPhoneBuilder.java | 32 ++++++++++- .../surfaces/wear/CN1SurfaceTileService.java | 30 +++++++++- 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java index c3fe2e7cbde..26536a1d66f 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java @@ -150,6 +150,61 @@ private void mirrorToWatch(String kindId, String timelineJson, Map 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) { + mirrorToWatch(kindId, new String(json, "UTF-8"), null); + } + } 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/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 21853fc63d9..46cf6602716 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 @@ -4182,7 +4182,7 @@ public void usesClassMethod(String cls, String method) { // 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); } @@ -8254,4 +8254,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/resources/com/codename1/builders/surfaces/wear/CN1SurfaceTileService.java b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1SurfaceTileService.java index 309142f705c..0e42f093311 100644 --- 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 @@ -268,7 +268,7 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s col.addContent(gap(spacing, false)); } first = false; - col.addContent(render(child, state, depth + 1, false)); + col.addContent(weighted(render(child, state, depth + 1, false), child, false)); } return col.setModifiers(modifiers(node)).build(); } @@ -281,7 +281,7 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s row.addContent(gap(spacing, true)); } first = false; - row.addContent(render(child, state, depth + 1, true)); + row.addContent(weighted(render(child, state, depth + 1, true), child, true)); } return row.setModifiers(modifiers(node)).build(); } @@ -355,6 +355,32 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s return styledText(node, state); } + /// 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 static 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); + 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 From 0696d628256e0ca43056d33d474adc6954773d69 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:40:01 +0300 Subject: [PATCH 32/96] Round fourteen review: the role rule, and two Tile fidelity gaps The role rule was still wrong for the case that motivated it. An app called fitness-wear WITH a companion returns fitness-wear and fitness-wear-wear, where nothing is unsuffixed -- so "is there a primary of this kind" answered no and classified both as primary, putting them on one path with directory order deciding which survived. What separates the two is the artifact the suffix points at: strip it, and a companion names something else in the set while a primary names nothing. Both readings of the name are now pinned, together. Tile text resolves semantic colours. ACCENT and SECONDARY_LABEL and the rest serialize as {"role": ...} with no light or dark value, so testing for "d" discarded every one of them. The renderer's own resolution is reused rather than copied -- a role meaning one thing on a home screen and another on a watch face is a bug nobody would look for -- always in its dark appearance, because a watch face composites over black and has no light one. Box children are placed where they asked to be. setAlignment serializes on the CHILD while a ProtoLayout Box carries the alignment of its contents, so adding children onto one shared Box gave every documented position the same default. Each child now gets a Box of its own, which is also what lets siblings sit in different corners. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceRenderer.java | 22 +++++--- .../com/codename1/maven/CN1BuildMojo.java | 50 ++++++++++------- .../surfaces/wear/CN1SurfaceTileService.java | 52 ++++++++++++++++-- .../builders/WearGlueCompilesTest.java | 2 + .../maven/CN1BuildResultArtifactRoleTest.java | 53 ++++++++++++++----- .../protolayout/LayoutElementBuilders.javas | 8 +++ 6 files changed, 146 insertions(+), 41 deletions(-) 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 b62f643a196..dba7d0ef938 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java @@ -847,28 +847,38 @@ 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; } /** 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 c6b0a60fa7b..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 @@ -1730,22 +1730,24 @@ private void createAntProject() throws IOException, LibraryPropertiesException, unzip.setDest(resultDir); unzip.execute(); File[] resultFiles = resultDir.listFiles(); - // Which extensions actually came back with a primary artifact, decided BEFORE any - // entry is classified. A role suffix is a claim about a set, not about a name: an app - // called "fitness-wear" returns one APK whose base ends in "-wear" and it is the - // primary artifact, not a companion. Treating the name alone as the answer copied the - // only APK to -wear.apk and attached it under a classifier, leaving the - // artifact the build was for missing entirely. - java.util.Set extensionsWithPrimary = new java.util.HashSet(); + // 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; } - if (roleSuffixOf(name.substring(0, dot)).length() == 0) { - extensionsWithPrimary.add(name.substring(dot)); + 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(); @@ -1760,7 +1762,7 @@ private void createAntProject() throws IOException, LibraryPropertiesException, // 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, extensionsWithPrimary); + 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 (roleSuffix.length() > 0) { @@ -2503,24 +2505,34 @@ protected void afterBuild() { /** * The role a result entry plays, given what else came back. * - *

A role suffix is a claim about a SET, not about a name. An app called + *

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, not a companion -- reading the name alone copied it to - * {@code -wear.apk}, attached it under a classifier, and left the artifact the - * build was actually for missing. So a suffix only counts when something else of the same - * kind came back to be the primary one.

+ * 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 extensionsWithPrimary extensions for which an unsuffixed entry was returned + * @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.Set extensionsWithPrimary) { - if (extensionsWithPrimary == null || !extensionsWithPrimary.contains(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 roleSuffixOf(base); + return siblings.contains(base.substring(0, base.length() - suffix.length())) + ? suffix : ""; } private static final String[] ARTIFACT_ROLE_SUFFIXES = {"-wear-debug", "-wear"}; 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 index 0e42f093311..71fd3a3945b 100644 --- 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 @@ -288,7 +288,7 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s if ("box".equals(type)) { LayoutElementBuilders.Box.Builder box = new LayoutElementBuilders.Box.Builder(); for (JSONObject child : children(node)) { - box.addContent(render(child, state, depth + 1, inRow)); + box.addContent(aligned(render(child, state, depth + 1, inRow), child)); } return box.setModifiers(modifiers(node)).build(); } @@ -355,6 +355,45 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s return styledText(node, state); } + /// A box child placed where its own node asked to be placed. + /// + /// 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. + private static LayoutElementBuilders.LayoutElement aligned( + LayoutElementBuilders.LayoutElement element, JSONObject child) { + 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; + } + return new LayoutElementBuilders.Box.Builder() + .addContent(element) + .setWidth(DimensionBuilders.expand()) + .setHeight(DimensionBuilders.expand()) + .setHorizontalAlignment(horizontal) + .setVerticalAlignment(vertical) + .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 @@ -446,8 +485,15 @@ private LayoutElementBuilders.LayoutElement styledText(JSONObject node, JSONObje font.setWeight(LayoutElementBuilders.FONT_WEIGHT_BOLD); } JSONObject color = node.optJSONObject("color"); - if (color != null && color.has("d")) { - font.setColor(ColorBuilders.argb(color.optInt("d"))); + 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) 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 index 13e88cb3d1e..0d9e52c7374 100644 --- 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 @@ -126,6 +126,8 @@ void theInjectedWearServicesCompile(@TempDir Path tmp) throws IOException { + "{ return \"\"; }\n" + " static double resolveFraction(JSONObject n, JSONObject s) " + "{ 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("CN1SurfaceActionActivity.java"), ("package com.codename1.impl.android.surfaces;\n" 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 index 1b6cf600b5b..8d2372aef3c 100644 --- 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 @@ -51,35 +51,62 @@ void everythingElseIsThePrimaryArtifact() { 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.Set none = new java.util.HashSet(); + java.util.Map> one = returned("fitness-wear.apk"); - assertEquals("", CN1BuildMojo.roleSuffixFor("fitness-wear", ".apk", none)); - assertEquals("", CN1BuildMojo.roleSuffixFor("fitness-wear-debug", ".apk", none)); + 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.Set apk = new java.util.HashSet(); - apk.add(".apk"); + 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("-wear", CN1BuildMojo.roleSuffixFor("myapp-wear", ".apk", apk)); - assertEquals("-wear-debug", CN1BuildMojo.roleSuffixFor("myapp-wear-debug", ".apk", apk)); - assertEquals("", CN1BuildMojo.roleSuffixFor("myapp", ".apk", apk)); + assertEquals("", CN1BuildMojo.roleSuffixFor("fitness-wear", ".apk", both)); + assertEquals("-wear", CN1BuildMojo.roleSuffixFor("fitness-wear-wear", ".apk", both)); } - /// Per extension, because a build can return a primary APK and no primary AAB. + /// Per extension, because a build can return a companion APK and no companion AAB. @Test void theQuestionIsAskedPerExtension() { - java.util.Set apkOnly = new java.util.HashSet(); - apkOnly.add(".apk"); + java.util.Map> mixed = + returned("myapp.apk", "myapp-wear.apk", "myapp-wear.aab"); - assertEquals("-wear", CN1BuildMojo.roleSuffixFor("myapp-wear", ".apk", apkOnly)); - assertEquals("", CN1BuildMojo.roleSuffixFor("myapp-wear", ".aab", apkOnly)); + assertEquals("-wear", CN1BuildMojo.roleSuffixFor("myapp-wear", ".apk", mixed)); + assertEquals("", CN1BuildMojo.roleSuffixFor("myapp-wear", ".aab", mixed)); } } 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 index 35549bb6e73..b225c1f2171 100644 --- 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 @@ -1,5 +1,11 @@ package androidx.wear.protolayout; public final class LayoutElementBuilders { + 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 { @@ -38,6 +44,8 @@ public final class LayoutElementBuilders { 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; } } From bf5f3b046578d3f46afe3c6d44d709ae94ce32e1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:25:32 +0300 Subject: [PATCH 33/96] Round fifteen review: Tile fidelity, and a listener that broke old ports The injected Data Layer listener no longer names the surfaces port classes at compile time. It is injected into EVERY build that references com.codename1.wearable, including a versioned build pinned to a 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. Looked up reflectively now, the way CN1WatchSurfaceNotifier reaches the androidx classes, and safe against R8 because the builder emits its surfaces keep rule exactly when the class is there to be found. Five Tile fidelity gaps, all the same shape -- a serialized field nothing read: - Cross-axis alignment. A column child declaring LEADING and a row child declaring TOP were passing only through the weight wrapper, so the alignment work from last round reached SurfaceBox alone. The wrapper is now axis-aware, expanding only the cross axis, because expanding a column child's height would push every sibling out. - Fixed sizes. setSize serializes w/h on any node and only the image branch read them, so text, dynamic text, progress and containers came out naturally sized. - Semantic backgrounds. A {"role": ...} background has no dark value, so the has("d") gate dropped it and the corner radius that only exists inside it. - Progress colours. Neither branch read the node's colour: the bar hard-coded white and the arc took its default. The track is now the fill's own colour at a quarter alpha, so a coloured bar reads as one bar. - Image tints. The port's decoder does not tint -- the widget renderer applies it at the ImageView -- so reusing only the decoder left every tinted glyph its original colour. ProtoLayout has the same separation and its own filter. Every one of these was typechecked against the real androidx.wear jars, and the stub tree corrected where it had been looser than the API. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/wear/CN1SurfaceTileService.java | 125 ++++++++++++++---- .../wearable/CN1WearableListenerService.java | 94 +++++++++++-- .../protolayout/LayoutElementBuilders.javas | 8 ++ 3 files changed, 194 insertions(+), 33 deletions(-) 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 index 71fd3a3945b..b756eb89d5d 100644 --- 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 @@ -79,6 +79,9 @@ public abstract class CN1SurfaceTileService extends TileService { 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 widget kind this Tile serves. Supplied by the generated subclass. */ protected abstract String getKindId(); @@ -268,9 +271,11 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s col.addContent(gap(spacing, false)); } first = false; - col.addContent(weighted(render(child, state, depth + 1, false), child, false)); + col.addContent(weighted( + aligned(render(child, state, depth + 1, false), child, true, false), + child, false)); } - return col.setModifiers(modifiers(node)).build(); + return sized(col.setModifiers(modifiers(node)).build(), node); } if ("row".equals(type)) { LayoutElementBuilders.Row.Builder row = new LayoutElementBuilders.Row.Builder(); @@ -281,16 +286,18 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s row.addContent(gap(spacing, true)); } first = false; - row.addContent(weighted(render(child, state, depth + 1, true), child, true)); + row.addContent(weighted( + aligned(render(child, state, depth + 1, true), child, false, true), + child, true)); } - return row.setModifiers(modifiers(node)).build(); + 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)); + box.addContent(aligned(render(child, state, depth + 1, inRow), child, true, true)); } - return box.setModifiers(modifiers(node)).build(); + return sized(box.setModifiers(modifiers(node)).build(), node); } if ("spacer".equals(type)) { // A spacer carries "min" and never "w"/"h" -- SurfaceSpacer.serializeContent writes @@ -314,12 +321,24 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s } if ("img".equals(type) || "vec".equals(type)) { String name = imageId(node); - return new LayoutElementBuilders.Image.Builder() + LayoutElementBuilders.Image.Builder image = new LayoutElementBuilders.Image.Builder() .setResourceId(name) .setWidth(DimensionBuilders.dp(Math.max(1, node.optInt("w", 24)))) .setHeight(DimensionBuilders.dp(Math.max(1, node.optInt("h", 24)))) - .setModifiers(modifiers(node)) - .build(); + .setModifiers(modifiers(node)); + // 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); @@ -334,28 +353,66 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s // 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); + 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 new LayoutElementBuilders.Box.Builder() + return sized(new LayoutElementBuilders.Box.Builder() .addContent(bar) .setModifiers(modifiers(node)) - .build(); + .build(), node); } // text, dyn and anything unknown: whatever string the node resolves to. A dyn value is // frozen here; see the class comment. - return styledText(node, state); + return sized(styledText(node, state), node); } - /// A box child placed where its own node asked to be placed. + /// 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 static 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); + 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 @@ -364,8 +421,12 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s /// 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 static LayoutElementBuilders.LayoutElement aligned( - LayoutElementBuilders.LayoutElement element, JSONObject child) { + 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; @@ -385,13 +446,17 @@ private static LayoutElementBuilders.LayoutElement aligned( || "bottomTrailing".equals(align)) { vertical = LayoutElementBuilders.VERTICAL_ALIGN_BOTTOM; } - return new LayoutElementBuilders.Box.Builder() + LayoutElementBuilders.Box.Builder box = new LayoutElementBuilders.Box.Builder() .addContent(element) - .setWidth(DimensionBuilders.expand()) - .setHeight(DimensionBuilders.expand()) .setHorizontalAlignment(horizontal) - .setVerticalAlignment(vertical) - .build(); + .setVerticalAlignment(vertical); + if (expandWidth) { + box.setWidth(DimensionBuilders.expand()); + } + if (expandHeight) { + box.setHeight(DimensionBuilders.expand()); + } + return box.build(); } /// A child sized to its share of the parent's leftover space, when it asked for one. @@ -439,7 +504,11 @@ private static LayoutElementBuilders.LayoutElement gap(int dips, boolean horizon /// 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. - private static LayoutElementBuilders.LayoutElement linearProgress(float fraction) { + /// + /// 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) { @@ -448,7 +517,7 @@ private static LayoutElementBuilders.LayoutElement linearProgress(float fraction .setHeight(DimensionBuilders.dp(6)) .setModifiers(new ModifiersBuilders.Modifiers.Builder() .setBackground(new ModifiersBuilders.Background.Builder() - .setColor(ColorBuilders.argb(0xFFFFFFFF)) + .setColor(ColorBuilders.argb(tint)) .build()) .build()) .build()); @@ -459,7 +528,7 @@ private static LayoutElementBuilders.LayoutElement linearProgress(float fraction .setHeight(DimensionBuilders.dp(6)) .setModifiers(new ModifiersBuilders.Modifiers.Builder() .setBackground(new ModifiersBuilders.Background.Builder() - .setColor(ColorBuilders.argb(0x40FFFFFF)) + .setColor(ColorBuilders.argb((tint & 0x00FFFFFF) | 0x40000000)) .build()) .build()) .build()); @@ -520,10 +589,16 @@ private ModifiersBuilders.Modifiers modifiers(JSONObject node) { .build()); } JSONObject bg = node.optJSONObject("bg"); - if (bg != null && bg.has("d")) { + 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(bg.optInt("d"))); + .setColor(ColorBuilders.argb(CN1SurfaceRenderer.resolveColor(bg, true, + 0x00000000, 0x00000000))); int corner = node.optInt("corner", 0); if (corner > 0) { background.setCorner(new ModifiersBuilders.Corner.Builder() 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 3ea88ff9c07..d70e15992de 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 @@ -356,6 +356,87 @@ public byte[] getData() { } } + /** 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} + */ + private void surfaceMirror(String method, String path, byte[] payload) { + Class mirror = mirrorClass(); + if (mirror == null) { + return; + } + try { + if (payload == null && "remove".equals(method)) { + mirror.getMethod(method, android.content.Context.class, String.class) + .invoke(null, this, path); + return; + } + mirror.getMethod(method, android.content.Context.class, String.class, byte[].class) + .invoke(null, this, path, payload); + } 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); + } + } + /** * The payload of a mirrored surface item. * @@ -450,17 +531,16 @@ && dataItemExists(uri)) { // 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 - && com.codename1.impl.android.surfaces.CN1SurfaceMirror.isMirrorPath(appPath)) { + && 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. if (deleted) { - com.codename1.impl.android.surfaces.CN1SurfaceMirror.remove(this, appPath); + surfaceMirror("remove", appPath, null); } else { - com.codename1.impl.android.surfaces.CN1SurfaceMirror.receive(this, appPath, - readMirrorPayload(event)); + surfaceMirror("receive", appPath, readMirrorPayload(event)); } continue; } @@ -532,12 +612,10 @@ && 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 (com.codename1.impl.android.surfaces.CN1SurfaceMirror - .isMirrorPath(transfer.logicalPath)) { + if (surfaceMirrorHandles(transfer.logicalPath)) { // Mirrored complication artwork. Stored beside the descriptor that names // it, without waking the app: see the data-item branch above. - com.codename1.impl.android.surfaces.CN1SurfaceMirror.receiveFile(this, - transfer.logicalPath, transfer.payload); + surfaceMirror("receiveFile", transfer.logicalPath, transfer.payload); CN1WearableBridge.confirmTransferDelivered(this, uri, transferSeq, true); continue; } 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 index b225c1f2171..44d182882d2 100644 --- 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 @@ -57,8 +57,15 @@ public final class LayoutElementBuilders { 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 setResourceId(String id) { return this; } public Builder setWidth(DimensionBuilders.DpProp d) { return this; } public Builder setHeight(DimensionBuilders.DpProp d) { return this; } @@ -70,6 +77,7 @@ public final class LayoutElementBuilders { 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; } } } From 9d3403a5440e7125d1a2e9edb3de745291c4c25c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:28:31 +0300 Subject: [PATCH 34/96] Activate the watch session where a background wake can reach it The review was right and I resolved that thread before fixing it. Putting the WCSession activation in initVM made it unreachable in the one launch it exists for: 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, initVM never with it, and the delegate that would receive the update is never installed. The phone half sends successfully into a session that has no listener, which is silent. It moves to the generated app delegate's applicationDidFinishLaunching, which runs on every launch whether or not a view appears. The hook is always declared in the bridging header and always defined in the generated bootstrap, so the Swift side calls it unconditionally and only its body depends on whether this build has a complication extension. Typechecked against the real watchOS SDK. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/IOSNative.m | 13 +++++---- .../builders/WatchNativeBuilder.java | 27 ++++++++++++++++++- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 307d3a47f6f..220e036521d 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -555,13 +555,6 @@ void com_codename1_impl_ios_IOSNative_initVM__(CN1_THREAD_STATE_MULTI_ARG JAVA_O // // Safe this early because the transition forwarders serial-dispatch onto the EDT, so a phase // released now queues BEHIND the app start this callback just scheduled. -#if defined(CN1_USE_WIDGETS) && defined(CN1_USE_WATCHCONNECTIVITY) - // Before the app starts, so a mirrored complication that arrives while the watch app is - // launching is not dropped. See cn1_watch_activate_connectivity: a surfaces-only watch app - // never calls a wearable native, so nothing else would ever activate the session. - extern void cn1_watch_activate_connectivity(void); - cn1_watch_activate_connectivity(); -#endif extern void cn1_watch_runtime_markJavaReady(void); cn1_watch_runtime_markJavaReady(); while (1) { @@ -16363,6 +16356,12 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_intentsIndexingSupported___R_boole // 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]; 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 7c468a6ed95..9445e3d732e 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 @@ -643,6 +643,14 @@ void writeWatchEntry(BuildRequest request, File appSrcDir) throws IOException { 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 @@ -768,7 +776,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") @@ -795,6 +815,11 @@ void writeWatchEntry(BuildRequest request, File appSrcDir) throws IOException { // 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 From 7ca2c09940ff6411404e5ef49e279f62262cb65f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:08:27 +0300 Subject: [PATCH 35/96] Round sixteen review: a reload is also a first delivery Both remirror paths send the artwork, not just the descriptor. A reload is how a watch app installed AFTER the publish gets its first copy of anything, and handing it a descriptor whose content-hash images have never existed on that device leaves permanent gaps until the app happens to publish again. The blobs are read back from the store rather than remembered, because a reload can be restarts away from the publish that produced them. Tile images honour the declared scale mode. "fill" crops to the bounds, which is what the rasterizer does with the same value; unset, every image took ProtoLayout's default and a fill image was fitted instead. FILL_BOUNDS is not the match -- it stretches, while the other renderers preserve the aspect ratio -- and "center" has no equivalent at all, which the code now says rather than mapping it silently. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceMirror.java | 53 +++++++++++++++++++ .../codename1/impl/ios/IOSSurfaceBridge.java | 37 ++++++++++++- .../surfaces/wear/CN1SurfaceTileService.java | 15 ++++++ .../protolayout/LayoutElementBuilders.javas | 4 ++ 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index 6fb32c7ef58..14b886eaaf6 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -158,6 +158,12 @@ public static void requestWatchReload(Context ctx, String kindId) { 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); @@ -170,6 +176,53 @@ public static void requestWatchReload(Context ctx, String kindId) { } } + /** + * 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; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java index 26536a1d66f..559fe1b9617 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java @@ -109,6 +109,36 @@ public void publishWidgetTimeline(String kindId, String timelineJson, mirrorToWatch(kindId, timelineJson, images); } + /// 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; + } + 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 t) { + // A blob that cannot be read is a gap in the mirrored surface, not a failed reload. + 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. /// @@ -198,7 +228,12 @@ private void remirror(String container, String kindId) { byte[] json = com.codename1.io.Util.readInputStream(in); in.close(); if (json.length > 0) { - mirrorToWatch(kindId, new String(json, "UTF-8"), null); + // 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 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 index b756eb89d5d..4867417ebb8 100644 --- 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 @@ -326,6 +326,21 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s .setWidth(DimensionBuilders.dp(Math.max(1, node.optInt("w", 24)))) .setHeight(DimensionBuilders.dp(Math.max(1, node.optInt("h", 24)))) .setModifiers(modifiers(node)); + // 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. 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 index 44d182882d2..7d41aab8f91 100644 --- 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 @@ -1,5 +1,8 @@ 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; @@ -66,6 +69,7 @@ public final class LayoutElementBuilders { 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; } From 7acb01953efa24f739b4389c2490075fdd860b06 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:48:24 +0300 Subject: [PATCH 36/96] Round seventeen review: art the descriptor names but the publish did not carry A SurfaceImage built from a previously registered name references a blob without shipping it, so the side-map a publish carries can be empty while the descriptor still names artwork -- and a watch installed since that art was first published rendered a gap for ever. The mirror now sends the STORE's copy, which after the publish write is exactly the set the descriptor references, so both the fresh and the re-referenced case are covered. It does mean a publish that changed only text re-sends unchanged art. Nothing deduplicated before either, the existing caps still bound it, and a transfer that was not needed costs a background stream while a missing one costs a hole in the watch face. Two more the independently generated Wear manifest was missing, both from the same family as the push and permission entries: the FileProvider, without which a watch that shares or opens a local file on API 24+ fails on a provider the shared sources assume; and the local-notification receiver, without which a notification the watch scheduled is not delivered when the app is not running. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceMirror.java | 22 ++++++++++++++----- .../builders/AndroidGradleBuilder.java | 19 ++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index 14b886eaaf6..e66accf8a84 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -84,7 +84,9 @@ private CN1SurfaceMirror() { * @param ctx any context * @param kindId the widget kind * @param timelineJson the serialized timeline - * @param images the imagery the timeline references + * @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) { @@ -111,8 +113,18 @@ public static void onPublished(Context ctx, String kindId, String timelineJson, + "previous timeline"); return; } - // Imagery first, so the descriptor is never live against art that has not landed. - sendImages(kindId, images); + // 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); @@ -137,8 +149,8 @@ public static void onPublished(Context ctx, String kindId, String timelineJson, * 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. No images: their names are content hashes, so whatever the descriptor - * references is already beside it.

+ * 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 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 e34b2502fc0..f35cd229df0 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 @@ -336,6 +336,12 @@ public File getGradleProjectDirectory() { /** 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 = ""; /// 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. @@ -4303,6 +4309,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { mediabuttonReceiver = ""; } String alarmRecevier = "\n"; + watchAlarmReceiver = alarmRecevier; String backgroundLocationReceiver = "\n"; if (!playServicesLocation) { backgroundLocationReceiver = ""; @@ -4779,6 +4786,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"); @@ -7555,6 +7568,12 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str + 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 // A complication or Tile tap still needs the trampoline, and a TILE tap needs it // reachable from the tile host's process -- see anyWatchTile. + " Date: Sat, 22 Aug 2026 20:29:20 +0300 Subject: [PATCH 37/96] Round eighteen review: the same registered-name gap on iOS The Android mirror was fixed last round and iOS was left as it was. Same reasoning: a SurfaceImage built from a previously registered name references a blob without shipping it, so the side-map a publish carries can be empty while the descriptor still names artwork -- and a watch installed since that art was first published rendered a gap until something else forced a full re-send. The container write has already happened when the mirror runs, so what is on disk is exactly the referenced set. Tile spacers keep their shared modifiers. Padding, a background with its corner radius and an action all come from the common modifier pass, and a ProtoLayout 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. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/com/codename1/impl/ios/IOSSurfaceBridge.java | 8 +++++++- .../builders/surfaces/wear/CN1SurfaceTileService.java | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java index 559fe1b9617..dd1d56509f4 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java @@ -106,7 +106,13 @@ public void publishWidgetTimeline(String kindId, String timelineJson, return; } nativeInstance.surfacesReloadTimelines(kindId); - mirrorToWatch(kindId, timelineJson, images); + // 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. 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 index 4867417ebb8..49360b3536e 100644 --- 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 @@ -314,9 +314,16 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s DimensionBuilders.SpacerDimension along = min > 0 ? DimensionBuilders.dp(min) : DimensionBuilders.expand(); DimensionBuilders.SpacerDimension across = DimensionBuilders.dp(1); - return new LayoutElementBuilders.Spacer.Builder() + // 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. + return new LayoutElementBuilders.Box.Builder() + .addContent(new LayoutElementBuilders.Spacer.Builder() .setWidth(inRow ? along : across) .setHeight(inRow ? across : along) + .build()) + .setModifiers(modifiers(node)) .build(); } if ("img".equals(type) || "vec".equals(type)) { From 3c58051d264f2f2f03772f489b60e646bb21b577 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:11:19 +0300 Subject: [PATCH 38/96] Round nineteen review: version codes, class names, labels, resource versions The Wear version code no longer consumes the next release's. Play refuses a version code it has already seen for an applicationId and the two artifacts share one, so an offset of 1 satisfied the ordering rule and then blocked the very next upload: ship phone 100 with Wear 101, and release 101 cannot go out. A project on sequential codes hits that on its second release. The default offset partitions the space instead, and a code pushed over Play's ceiling is refused with the hint named rather than silently truncated. Two kinds can no longer share a generated class. Folding away underscores is not injective -- "status" and "status_" both read as Status -- so the second generated class overwrote the first and both manifest entries pointed at it, one kind serving the other kind's data. The underscore POSITIONS go in the name, not a count, because a count separates those two and not "a__b" from "a_b_". An id without underscores is unchanged, so no existing project is renamed. A display name containing a double quote no longer breaks the manifest. xmlize handles the three characters that matter in element content and leaves the quote alone, which is right there and closes the attribute early inside android:label="...". Tile resources are served for the version the host asked about. That version belongs to the layout it is SHOWING, which can be one entry old when a flip or a publish lands between the two callbacks -- and re-reading the current entry answered with a different resource map, leaving the displayed layout's image ids unresolved on a Tile that had just been refreshed. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 70 +++++++++++++++++-- .../surfaces/wear/CN1SurfaceTileService.java | 32 ++++++++- .../AndroidWatchSurfaceCodegenTest.java | 55 ++++++++++++++- .../androidx/wear/tiles/RequestBuilders.javas | 4 +- 4 files changed, 148 insertions(+), 13 deletions(-) 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 f35cd229df0..09e88faea05 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 @@ -6982,6 +6982,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("<", "<"); @@ -7017,11 +7033,13 @@ static String xmlize(String s) { */ static String surfaceKindClassSuffix(String kindId) { StringBuilder sb = new StringBuilder(kindId.length()); + StringBuilder positions = new StringBuilder(); boolean upper = true; for (int i = 0; i < kindId.length(); i++) { char c = kindId.charAt(i); if (c == '_') { upper = true; + positions.append('_').append(i); continue; } if (upper) { @@ -7031,7 +7049,17 @@ static String surfaceKindClassSuffix(String kindId) { sb.append(c); } } - return sb.toString(); + // Folding away underscores is not injective: "status" and "status_" both read as Status, + // and two kinds sharing a suffix share a generated class -- the second overwrites the + // first and both manifest entries point at it, so one kind serves the other kind's data. + // Both ids are legal, so they have to be kept apart rather than one refused. + // + // The positions and not a count: a count separates "status" from "status_" but not + // "a__b" from "a_b_", which both discard two. Ids are [a-z][a-z0-9_]*, so the folded + // name plus where the underscores were is the whole original -- nothing else can produce + // the same pair. Absent for an id without underscores, so every name in an existing + // project is unchanged. + return sb.append(positions).toString(); } /** @@ -7206,7 +7234,7 @@ private String complicationServiceEntry(BuildRequest request, String className, String supportedTypes) { return " \n" + " \n" @@ -7224,7 +7252,7 @@ private String complicationServiceEntry(BuildRequest request, String className, private String tileServiceEntry(String className, String label) { return " \n" + " \n" @@ -7295,6 +7323,20 @@ private String wearApplicationTag(BuildRequest request) { 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. @@ -7633,10 +7675,24 @@ static int wearVersionCode(BuildRequest request, int intVersion) throws BuildExc resolved = parseIntSafe(explicit, intVersion + 1); setting = "android.watchVersionCode=" + explicit; } else { - resolved = intVersion + parseIntSafe( - request.getArg("android.watchVersionCodeOffset", "1"), 1); - setting = "android.watchVersionCodeOffset=" - + request.getArg("android.watchVersionCodeOffset", "1"); + 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 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 index 49360b3536e..5ebea685ffb 100644 --- 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 @@ -83,6 +83,10 @@ public abstract class CN1SurfaceTileService extends TileService { /// value CN1SurfaceRenderer tints a widget's progress bar with. private static final int ACCENT = 0xff007aff; + /// The entry the last Tile was built from, so its resources can still be served after the + /// timeline has moved on. Volatile because the two callbacks arrive on different threads. + private volatile CN1WatchSurface.Reading lastServed; + /** The widget kind this Tile serves. Supplied by the generated subclass. */ protected abstract String getKindId(); @@ -109,7 +113,7 @@ protected ListenableFuture onTileResourcesRequest( public Object attachCompleter( CallbackToFutureAdapter.Completer completer) { - completer.set(buildResources()); + completer.set(buildResources(request.getVersion())); return "cn1TileResources"; } }); @@ -129,6 +133,8 @@ private TileBuilders.Tile buildTile() { freshness = freshnessFor(reading.getNextFlipDate(), hasDynamicText(reading.getLayout())); version = resourcesVersion(reading); + // Kept for the resources request that follows, which asks about THIS version. + lastServed = reading; } } catch (Throwable t) { // A Tile that throws is removed from the carousel, so a malformed descriptor must @@ -210,12 +216,32 @@ private static String resourcesVersion(CN1WatchSurface.Reading reading) { + "|" + String.valueOf(reading.getState())).hashCode()); } - private ResourceBuilders.Resources buildResources() { + /** + * 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. Anything else falls back to the current entry, which is the best available answer + * for a version this process has no memory of.

+ * + * @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 = lastServed; CN1WatchSurface.Reading reading = - CN1WatchSurface.read(this, getKindId(), "watchRectangular"); + remembered != null && requested != null + && requested.equals(resourcesVersion(remembered)) + ? remembered + : CN1WatchSurface.read(this, getKindId(), "watchRectangular"); if (reading != null) { version = resourcesVersion(reading); for (Map.Entry e 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 index 77e27306a9f..b78146af13f 100644 --- 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 @@ -222,7 +222,8 @@ void phoneFamiliesContributeNoComplicationTypes() { /// entirely, so the phone APK still wins there. @Test void theWearArtifactOutranksThePhoneOne() throws BuildException { - assertEquals(101, AndroidGradleBuilder.wearVersionCode(request(), 100)); + assertEquals(100 + AndroidGradleBuilder.DEFAULT_WATCH_VERSION_CODE_OFFSET, + AndroidGradleBuilder.wearVersionCode(request(), 100)); } @Test @@ -247,7 +248,8 @@ void aMalformedVersionHintFallsBackToTheDefault() throws BuildException { BuildRequest req = request(); req.putArgument("android.watchVersionCodeOffset", "not a number"); - assertEquals(101, AndroidGradleBuilder.wearVersionCode(req, 100)); + 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 @@ -275,6 +277,55 @@ void aWearVersionCodeThatDoesNotOutrankThePhoneIsRefused() { () -> 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. Folding away underscores does not: + /// "status" and "status_" both read as Status, so the second generated class overwrote the + /// first and both manifest entries pointed at it -- one kind serving the other kind's data. + /// A count is not enough either, because "a__b" and "a_b_" discard the same number. + @Test + void twoKindsNeverShareAGeneratedClassName() { + assertEquals("Status", AndroidGradleBuilder.surfaceKindClassSuffix("status")); + assertEquals("BatteryLevel", AndroidGradleBuilder.surfaceKindClassSuffix("battery_level") + .replaceAll("_\\d+", "")); + + java.util.Set seen = new java.util.HashSet(); + String[] ids = {"status", "status_", "_status", "a_b", "ab_", "a__b", "a_b_", "_a_b"}; + for (String id : ids) { + assertTrue(seen.add(AndroidGradleBuilder.surfaceKindClassSuffix(id)), + "two ids produced the same class suffix, one of them '" + id + "'"); + } + } + + /// 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")); + } + // --- tiles ------------------------------------------------------------------ /// Only the rectangular family is roomy enough for a layout rather than a readout, so it is 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 index bd13972bd03..b2ef72050a7 100644 --- 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 @@ -1,5 +1,7 @@ package androidx.wear.tiles; public final class RequestBuilders { public static class TileRequest { } - public static class ResourcesRequest { } + public static class ResourcesRequest { + public String getVersion() { return null; } + } } From eed7a5d196ff6ace75daf0fd73c3ee94c2a58f86 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:49:32 +0300 Subject: [PATCH 39/96] Round twenty review: three small ones with visible consequences A malformed android.watchVersionCode is refused. Substituting intVersion + 1 hid the typo AND recreated the collision the method exists to prevent -- the Wear artifact consuming the next phone release's code -- while the developer looked at a hint that said something else entirely. Service labels are escaped once. The label was xmlized on the way in and escaped again in the attribute, so "A & B" reached the manifest as "A &amp; B" and the watch face showed "A & B". The entry builders own the escaping now, because they are the ones that know it is going into an attribute. An unsized Tile image keeps its natural size. setSize documents 0 as natural and the wire omits an axis left at it, so a default of 24 shrank every unsized image to 24x24 and turned setSize(100, 0) into 100x24. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 18 ++++++++++++++++-- .../surfaces/wear/CN1SurfaceTileService.java | 14 ++++++++++++-- .../AndroidWatchSurfaceCodegenTest.java | 14 ++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) 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 09e88faea05..fddfe1d96ec 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 @@ -7162,7 +7162,10 @@ private String generateWatchSurfaces(BuildRequest request, File srcDir, File res StringBuilder kindIds = new StringBuilder(); for (String[] kind : watchSurfaceKinds) { String kindId = kind[0]; - String label = xmlize(kind[1]); + // 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 = surfaceKindClassSuffix(kindId); writeWatchService(surfacesDir, implDir, "CN1Complication_" + suffix, @@ -7672,7 +7675,18 @@ static int wearVersionCode(BuildRequest request, int intVersion) throws BuildExc int resolved; String setting; if (explicit.length() > 0) { - resolved = parseIntSafe(explicit, intVersion + 1); + // 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", 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 index 5ebea685ffb..76994134dd0 100644 --- 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 @@ -356,9 +356,19 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s String name = imageId(node); LayoutElementBuilders.Image.Builder image = new LayoutElementBuilders.Image.Builder() .setResourceId(name) - .setWidth(DimensionBuilders.dp(Math.max(1, node.optInt("w", 24)))) - .setHeight(DimensionBuilders.dp(Math.max(1, node.optInt("h", 24)))) .setModifiers(modifiers(node)); + // Only the axes the node actually declared. setSize documents 0 as "natural", and the + // wire omits an axis left at it -- so a default of 24 shrank every unsized image to + // 24x24 and turned setSize(100, 0) into 100x24. An axis left unset keeps ProtoLayout's + // own sizing, which is the natural one. + int iw = node.optInt("w", 0); + int ih = node.optInt("h", 0); + if (iw > 0) { + image.setWidth(DimensionBuilders.dp(iw)); + } + if (ih > 0) { + image.setHeight(DimensionBuilders.dp(ih)); + } // 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. 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 index b78146af13f..690f7a75d71 100644 --- 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 @@ -326,6 +326,20 @@ void anIdWithoutUnderscoresIsUnchanged() { 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)); + } + // --- tiles ------------------------------------------------------------------ /// Only the rectangular family is roomy enough for a layout rather than a readout, so it is From 39bc7c2e688f4ce39b16cfb575fac14d12bb8651 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:28:09 +0300 Subject: [PATCH 40/96] Round twenty-one review: four, including one of my own from last round Tile resource snapshots are keyed by version. Remembering the last reading in one slot was my fix for the flip race and had the same shape of bug in it: two tile requests can be handled before the first one's resource callback arrives, and the second overwrote the first -- so the older layout fell back to whatever was current and its image ids went unresolved, which is what the remembering was for. Two are kept, which covers the interleaving without a long-lived Tile process accumulating readings. The legacy Play services case says what it is doing. A companion with android.includeGPlayServices and watch families cannot have the Data Layer glue -- the wearable artifact conflicts with the monolith -- and skipping it silently left the developer with a complication that never updates from the phone and no reason for it. The watch's own publish still works, so this is a loud log rather than a refusal. The Wear manifest declares the background-work service, without which a watch lifecycle calling Display.scheduleBackgroundWork schedules a component the manifest does not declare. The Wear application label honours android.blockLabel and is escaped for an attribute, so a display name like Acme "Watch" no longer closes the attribute early and stops the manifest parsing. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 31 +++++++++++++- .../surfaces/wear/CN1SurfaceTileService.java | 42 +++++++++++++++---- 2 files changed, 62 insertions(+), 11 deletions(-) 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 fddfe1d96ec..cf223c2536c 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 @@ -342,6 +342,9 @@ public File getGradleProjectDirectory() { /** The local-notification receiver the Wear manifest needs too. */ private String watchAlarmReceiver = ""; + + /** The JobScheduler service declaration the Wear manifest needs too. */ + private String watchBackgroundWorkService = ""; /// 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. @@ -3647,6 +3650,20 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + "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"); @@ -4321,6 +4338,10 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { // (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; @@ -7314,8 +7335,13 @@ private String launcherTheme() { private String wearApplicationTag(BuildRequest request) { String attrs = request.getArg("android.xapplication_attr", ""); StringBuilder sb = new StringBuilder(" A map and not one slot: two tile requests can be handled before the first one's resource + * callback arrives, and a single slot would then have been overwritten by the second -- the + * first layout falling back to whatever is current and its image ids going unresolved, which + * is the failure this remembering exists to prevent. Two entries are enough for that + * interleaving and the oldest is dropped, so a long-lived Tile process does not accumulate + * readings.

+ * + *

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() > 2) { + 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(); @@ -134,7 +161,7 @@ private TileBuilders.Tile buildTile() { hasDynamicText(reading.getLayout())); version = resourcesVersion(reading); // Kept for the resources request that follows, which asks about THIS version. - lastServed = reading; + remember(version, reading); } } catch (Throwable t) { // A Tile that throws is removed from the carousel, so a malformed descriptor must @@ -236,11 +263,8 @@ private ResourceBuilders.Resources buildResources(String requested) { ResourceBuilders.Resources.Builder builder = new ResourceBuilders.Resources.Builder(); String version = "0"; try { - CN1WatchSurface.Reading remembered = lastServed; - CN1WatchSurface.Reading reading = - remembered != null && requested != null - && requested.equals(resourcesVersion(remembered)) - ? remembered + CN1WatchSurface.Reading remembered = recall(requested); + CN1WatchSurface.Reading reading = remembered != null ? remembered : CN1WatchSurface.read(this, getKindId(), "watchRectangular"); if (reading != null) { version = resourcesVersion(reading); From 482ed5e9952e8951e55d97b9977ee158fbf50607 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:09:04 +0300 Subject: [PATCH 41/96] Round twenty-two review: a tap that arrives before the runtime 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, because it brings the VM up on a pthread and returns. Handling it there reached into a half-built runtime to make Java strings and call into Java. The URL now waits in one slot and is handed over by cn1_watch_runtime_markJavaReady, the same readiness the lifecycle phases already queue behind. One slot rather than a queue: a launch carries one URL, and if a second arrived first the newest is the one the user just tapped. A Tile honours reload-at-end. RELOAD_AT_END is the default and means the last entry stays up while the app is asked -- throttled -- to publish fresh content; a widget already does this and a Tile that skipped it froze on its final entry until something else published. The widget's own request is reused rather than reimplemented, so one document cannot mean two things. A mirrored file transfer is confirmed only if it was stored. The reflective helper catches whatever the mirror throws, and a claim made anyway is durable: the sender stops retrying and the artwork is gone for good. The helper now says whether it took it. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1WatchSurface.java | 29 +++++++++-- .../android/surfaces/CN1WidgetProvider.java | 5 +- Ports/iOSPort/nativeSources/CN1WatchRuntime.m | 11 +++++ Ports/iOSPort/nativeSources/IOSNative.m | 48 ++++++++++++++++++- .../surfaces/wear/CN1SurfaceTileService.java | 6 +++ .../wearable/CN1WearableListenerService.java | 21 ++++++-- .../builders/WearGlueCompilesTest.java | 6 +++ 7 files changed, 116 insertions(+), 10 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java index 7c7bd542fa3..363126965c5 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java @@ -69,16 +69,36 @@ public static final class Reading { 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; } /** @@ -138,7 +158,8 @@ public static Reading read(Context ctx, String kindId, String family) { 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)); + 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. @@ -179,7 +200,8 @@ public static List readTimeline(Context ctx, String kindId, String fami if (active != null) { JSONObject state = active.optJSONObject("state"); out.add(new Reading(layout, state == null ? new JSONObject() : state, - nextFlipDate(entries, now), 0L)); + 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); @@ -193,7 +215,8 @@ public static List readTimeline(Context ctx, String kindId, String fami } JSONObject state = e.optJSONObject("state"); out.add(new Reading(layout, state == null ? new JSONObject() : state, - nextFlipDate(entries, date), date)); + 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 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..881a81f9dad 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java @@ -143,7 +143,10 @@ 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. + static void requestAppRefresh(Context context, String kindId) { try { String listenerClass = CN1SurfaceStore.getBackgroundFetchClass(context); if (listenerClass == null) { 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/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 220e036521d..63a36e953a8 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -15380,8 +15380,29 @@ BOOL cn1HandleSurfaceURL(NSURL *url) { } #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; + void cn1_watch_surface_url(const char *url) { if (url == NULL) { return; @@ -15389,10 +15410,35 @@ void cn1_watch_surface_url(const char *url) { POOL_BEGIN(); NSString *str = [NSString stringWithUTF8String:url]; if (str != nil) { - cn1HandleSurfaceURL([NSURL URLWithString:str]); + extern int cn1_watch_runtime_isJavaReady(void); + if (cn1_watch_runtime_isJavaReady()) { + cn1HandleSurfaceURL([NSURL URLWithString:str]); + } else { + @synchronized ([CN1WatchSurfaceURLLock class]) { + [cn1WatchPendingSurfaceURL release]; + cn1WatchPendingSurfaceURL = [str retain]; + } + } } 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]) { + 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) { 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 index 10094891113..802d3841443 100644 --- 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 @@ -162,6 +162,12 @@ private TileBuilders.Tile buildTile() { 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 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 d70e15992de..788d2207aef 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 @@ -413,20 +413,24 @@ private static boolean surfaceMirrorHandles(String path) { * @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 */ - private void surfaceMirror(String method, String path, byte[] payload) { + private boolean surfaceMirror(String method, String path, byte[] payload) { Class mirror = mirrorClass(); if (mirror == null) { - return; + return false; } try { if (payload == null && "remove".equals(method)) { mirror.getMethod(method, android.content.Context.class, String.class) .invoke(null, this, path); - return; + return true; } mirror.getMethod(method, android.content.Context.class, String.class, byte[].class) .invoke(null, this, path, payload); + return true; } 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 @@ -434,6 +438,7 @@ private void surfaceMirror(String method, String path, byte[] payload) { // failures the mirror reports itself. android.util.Log.w("CN1Surfaces", "Could not hand " + path + " to the surface mirror", t); + return false; } } @@ -615,8 +620,14 @@ && surfaceMirrorHandles(appPath)) { if (surfaceMirrorHandles(transfer.logicalPath)) { // Mirrored complication artwork. Stored beside the descriptor that names // it, without waking the app: see the data-item branch above. - surfaceMirror("receiveFile", transfer.logicalPath, transfer.payload); - CN1WearableBridge.confirmTransferDelivered(this, uri, transferSeq, true); + // 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. An unconfirmed transfer is redelivered, which + // is the whole point of the acknowledgement. + CN1WearableBridge.confirmTransferDelivered(this, uri, transferSeq, + surfaceMirror("receiveFile", transfer.logicalPath, + transfer.payload)); continue; } if (!started) { 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 index 0d9e52c7374..4e0a9af3a14 100644 --- 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 @@ -129,6 +129,12 @@ void theInjectedWearServicesCompile(@TempDir Path tmp) throws IOException { + " 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" + + "}\n").getBytes("UTF-8")); Files.write(shims.resolve("CN1SurfaceActionActivity.java"), ("package com.codename1.impl.android.surfaces;\n" + "public class CN1SurfaceActionActivity {\n" From 445ebf5b760b30a86d15e347fa116f6b05c77b16 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:48:26 +0300 Subject: [PATCH 42/96] Round twenty-three review: an unset image dimension is not a natural one I made both Tile image axes optional last round on the premise that an omitted axis preserves the natural size. The library says the opposite: "if not defined, the image will not be rendered". So an undeclared axis takes a default again, and the comment now records what that actually costs -- a Tile shows an unsized image at 24dp where the rasterizer would use the bitmap's own size -- because ProtoLayout has no expression for "as tall as the bitmap is". The alternative is not a better size but no image at all. An acknowledgement follows the write, all the way down. Last round's fix made the reflective helper report an exception escaping the call; receiveFile catches its own write failures internally and returned normally, so the helper still said yes and the durable claim was still made. receiveFile now reports whether it stored anything and the helper passes that through -- a false answer gets the transfer redelivered, a wrongly true one loses the artwork for good. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceMirror.java | 16 +++++++++---- .../surfaces/wear/CN1SurfaceTileService.java | 24 +++++++++---------- .../wearable/CN1WearableListenerService.java | 10 ++++++-- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index e66accf8a84..76790cce4c6 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -319,24 +319,27 @@ public static void receive(Context ctx, String path, byte[] payload) { * @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 */ - public static void receiveFile(Context ctx, String path, byte[] payload) { + public static boolean receiveFile(Context ctx, String path, byte[] payload) { try { String kindId = kindOf(path); if (kindId == null || payload == null) { - return; + 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; + 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; + return false; } File dir = CN1SurfaceStore.kindDir(ctx, kindId); // Written whatever the descriptor on disk currently says, and deliberately so. @@ -362,9 +365,14 @@ public static void receiveFile(Context ctx, String path, byte[] payload) { // and nothing else would ask again until the next publish. So each arriving image // asks too. CN1WatchSurfaceNotifier.requestUpdate(ctx, kindId); + 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; } /** 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 index 802d3841443..14d86245351 100644 --- 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 @@ -387,18 +387,18 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s LayoutElementBuilders.Image.Builder image = new LayoutElementBuilders.Image.Builder() .setResourceId(name) .setModifiers(modifiers(node)); - // Only the axes the node actually declared. setSize documents 0 as "natural", and the - // wire omits an axis left at it -- so a default of 24 shrank every unsized image to - // 24x24 and turned setSize(100, 0) into 100x24. An axis left unset keeps ProtoLayout's - // own sizing, which is the natural one. - int iw = node.optInt("w", 0); - int ih = node.optInt("h", 0); - if (iw > 0) { - image.setWidth(DimensionBuilders.dp(iw)); - } - if (ih > 0) { - image.setHeight(DimensionBuilders.dp(ih)); - } + // 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. 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 788d2207aef..1131e15e145 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 @@ -428,9 +428,15 @@ private boolean surfaceMirror(String method, String path, byte[] payload) { .invoke(null, this, path); return true; } - mirror.getMethod(method, android.content.Context.class, String.class, byte[].class) + Object answer = mirror + .getMethod(method, android.content.Context.class, String.class, byte[].class) .invoke(null, this, path, payload); - return true; + // 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 From 6e0564a5835eb8873a1bf89b249ce077e9e593a9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:28:21 +0300 Subject: [PATCH 43/96] Round twenty-four review: the queue's own race, and a write nobody checked Readiness and the pending slot move under one lock. Asking whether Java is ready and then storing is two steps, and the VM thread could become ready and drain an empty slot between them -- the URL landing a moment later and never being looked at again. The drain sets the flag while holding the same lock, so a tap either lands in the slot before it is emptied or delivers itself afterwards. The delivery itself stays outside the lock, because it calls into Java and the VM thread takes that lock too. A failed artwork write aborts the timeline swap. writeToFile:atomically: returns NO when the container cannot take it, and installing the descriptor anyway made a timeline live against art that is not there -- then the collection that follows deleted what the PREVIOUS descriptor was still using, leaving the watch worse off than if nothing had arrived. The old timeline stays, which is a complete surface, and the next publish or reload sends the whole set again. A withdrawal deletes the descriptor first. A Data Layer deletion is consumed once and cannot be redelivered, so the removal has one attempt: what actually makes the surface go away is the timeline being gone, and art left behind is clutter the next publish collects. There is nothing to retry against, which the code now says rather than leaving the next reader to wonder. The Wear manifest declares the background-fetch handler and its trampoline, without which a watch lifecycle that asks for background fetch is never woken. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceMirror.java | 12 ++++++ Ports/iOSPort/nativeSources/IOSNative.m | 41 +++++++++++++++---- .../builders/AndroidGradleBuilder.java | 10 +++++ .../wearable/CN1WearableListenerService.java | 4 ++ 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index 76790cce4c6..1739504033e 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -400,6 +400,18 @@ public static void remove(Context ctx, String path) { // 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()) { + Log.w(TAG, "Could not delete " + timeline + ", so the watch may keep showing a " + + "surface the phone withdrew. The deletion cannot be redelivered; the " + + "next publish of " + kindId + " will replace it."); + } File[] files = kindDir.listFiles(); if (files != null) { for (File f : files) { diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 63a36e953a8..def9b220700 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -15403,6 +15403,15 @@ @implementation CN1WatchSurfaceURLLock // 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; @@ -15410,15 +15419,20 @@ void cn1_watch_surface_url(const char *url) { POOL_BEGIN(); NSString *str = [NSString stringWithUTF8String:url]; if (str != nil) { - extern int cn1_watch_runtime_isJavaReady(void); - if (cn1_watch_runtime_isJavaReady()) { - cn1HandleSurfaceURL([NSURL URLWithString:str]); - } else { - @synchronized ([CN1WatchSurfaceURLLock class]) { + 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(); } @@ -15429,6 +15443,9 @@ void cn1_watch_surface_url(const char *url) { 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; } @@ -15730,10 +15747,20 @@ void cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, // malformed payload from writing outside the kind's own directory. continue; } - [[imageBlobs objectAtIndex:i] + if (![[imageBlobs objectAtIndex:i] writeToFile:[kindDir stringByAppendingPathComponent: [name stringByAppendingString:@".png"]] - atomically:YES]; + 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; + } } if (![json writeToFile:[kindDir stringByAppendingPathComponent:@"timeline.json"] atomically:YES]) { 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 cf223c2536c..fdc44a248ce 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 @@ -345,6 +345,9 @@ public File getGradleProjectDirectory() { /** 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 = ""; /// 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. @@ -4334,6 +4337,12 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { 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. @@ -7646,6 +7655,7 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str + " " + watchProviderTag + "\n" + " " + watchAlarmReceiver + " " + watchBackgroundWorkService + + " " + watchBackgroundFetchService // A complication or Tile tap still needs the trampoline, and a TILE tap needs it // reachable from the tile host's process -- see anyWatchTile. + " Date: Sun, 23 Aug 2026 01:06:31 +0300 Subject: [PATCH 44/96] Round twenty-five review: coverage in the wrong module, and three more The CI harness no longer installs the test suite's own configuration in a companion module. Passing the Wear module through --app to pin its SDK levels also ran the instrumentation runner, the test dependencies and the coverage report task over it -- and a Wear module has no instrumentation sources, so that is configuration for tests that do not exist and a report finalizer that fails on a module with nothing to report. The pins apply to every application module, as they must; everything else applies to the module the suite actually runs against. The Wear manifest declares the location, geofence and foreground-service components. A watch lifecycle reaches them through the same shared implementation, and the wear module compiles them either way, so the declarations were the only thing missing -- and an undeclared component cannot be started. A complication's content description is the untruncated text again. shortText shortens what it displays and keeps what it is given as the description, so shortening first handed a screen reader the same seven characters the slot already shows. The Tile keeps more than two resource snapshots. Nothing bounds how many requests the host has outstanding, and two was chosen for the interleaving I had in mind rather than the one that can happen. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 12 ++++++ .../wear/CN1ComplicationDataSource.java | 6 ++- .../surfaces/wear/CN1SurfaceTileService.java | 18 ++++---- scripts/android/lib/PatchGradleFiles.java | 42 +++++++++++++------ 4 files changed, 58 insertions(+), 20 deletions(-) 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 fdc44a248ce..edba0bebd46 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 @@ -348,6 +348,9 @@ public File getGradleProjectDirectory() { /** 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 = ""; /// 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. @@ -4358,6 +4361,14 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { 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 @@ -7656,6 +7667,7 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str + " " + watchAlarmReceiver + " " + watchBackgroundWorkService + " " + watchBackgroundFetchService + + " " + watchFeatureComponents // A complication or Tile tap still needs the trampoline, and a TILE tap needs it // reachable from the tile host's process -- see anyWatchTile. + " 1 ? shorten(texts.get(1)) : 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. + return shortText(texts.get(0), texts.size() > 1 ? shorten(texts.get(1)) : null, tap); } return null; 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 index 14d86245351..c114490ae6c 100644 --- 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 @@ -83,15 +83,19 @@ public abstract class CN1SurfaceTileService extends TileService { /// value CN1SurfaceRenderer tints a widget's progress bar with. private static final int ACCENT = 0xff007aff; + /// 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: two tile requests can be handled before the first one's resource - * callback arrives, and a single slot would then have been overwritten by the second -- the - * first layout falling back to whatever is current and its image ids going unresolved, which - * is the failure this remembering exists to prevent. Two entries are enough for that - * interleaving and the oldest is dropped, so a long-lived Tile process does not accumulate - * readings.

+ *

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.

@@ -102,7 +106,7 @@ public abstract class CN1SurfaceTileService extends TileService { /** 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() > 2) { + while (served.size() > MAX_REMEMBERED_READINGS) { java.util.Iterator oldest = served.keySet().iterator(); oldest.next(); oldest.remove(); diff --git a/scripts/android/lib/PatchGradleFiles.java b/scripts/android/lib/PatchGradleFiles.java index 062071026ac..8d3585be372 100644 --- a/scripts/android/lib/PatchGradleFiles.java +++ b/scripts/android/lib/PatchGradleFiles.java @@ -68,7 +68,11 @@ public static void main(String[] args) throws Exception { System.out.println("Skipping absent module build.gradle " + module); continue; } - if (patchAppBuildGradle(module, arguments.compileSdk, arguments.targetSdk)) { + // 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; } @@ -126,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; @@ -134,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); From 370a599eaab9d3dd56250241b40f2fc6f3a33a39 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:46:10 +0300 Subject: [PATCH 45/96] Round twenty-six review: the watchOS floor was wrong about its own code The extension targeted watchOS 10 because containerBackground(for:) is watchOS 10. That is true of the API and not of the code: CN1DescriptorWidget applies it inside 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. The floor is WidgetKit's own now, so a watch still on 9 gets a complication that works, losing only the background. A Tile image resource id includes what it renders. 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. A complication honours reload-at-end. Handing the system a timeline means it holds the final entry indefinitely, and UPDATE_PERIOD_SECONDS is 0, so nothing would ever ask again. The widget and the Tile both make this throttled request; the complication was the last surface that could freeze on its own last entry. Co-Authored-By: Claude Opus 5 (1M context) --- .../util/IOSWidgetExtensionBuilder.java | 21 +++++++++++-------- .../wear/CN1ComplicationDataSource.java | 10 +++++++++ .../surfaces/wear/CN1SurfaceTileService.java | 8 ++++++- .../IOSWidgetExtensionWatchTargetTest.java | 19 ++++++++++------- 4 files changed, 40 insertions(+), 18 deletions(-) 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 ac61e51f8cd..17710c0fc8f 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 @@ -107,14 +107,17 @@ public class IOSWidgetExtensionBuilder { }; /** - * Lowest watchOS the generated extension can target. + * Lowest watchOS the generated extension can target: WidgetKit's own floor. * - *

Not WidgetKit's own watchOS 9 floor: {@code containerBackground(for:)}, which every - * generated widget applies, is watchOS 10. Below 10.0 the availability check around it - * stops compiling, so 9.0 would not merely lose the background -- it would fail the - * build.

+ *

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 = "10.0"; + public static final String WATCH_MIN_DEPLOYMENT_TARGET = "9.0"; /** * One widget kind declared in surfaces.json. Ids must match @@ -369,9 +372,9 @@ private void validate() { } if (watchTarget && compareVersions(deploymentTarget, WATCH_MIN_DEPLOYMENT_TARGET) < 0) { throw new IllegalStateException("the watch widget extension cannot target watchOS " - + deploymentTarget + ": containerBackground(for:), which every generated " - + "widget applies, is watchOS " + WATCH_MIN_DEPLOYMENT_TARGET - + " and its availability check does not compile below that"); + + 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())) { 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 index 43476016357..09580bbd561 100644 --- 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 @@ -134,6 +134,7 @@ private ComplicationDataTimeline buildTimeline(ComplicationType type) { return null; } List entries = new ArrayList(); + CN1WatchSurface.Reading last = readings.get(0); for (int i = 1; i < readings.size(); i++) { CN1WatchSurface.Reading reading = readings.get(i); ComplicationData entry = build(type, reading); @@ -148,6 +149,15 @@ private ComplicationDataTimeline buildTimeline(ComplicationType type) { new TimeInterval(Instant.ofEpochMilli(reading.getStart()), end > reading.getStart() ? Instant.ofEpochMilli(end) : Instant.MAX), entry)); + last = reading; + } + // Exhausted, and the app asked to be woken when that happened. The system will hold the + // final entry indefinitely -- that is what handing it a timeline means -- and + // UPDATE_PERIOD_SECONDS is 0, so nothing else would ever ask. A widget and a Tile both + // make this throttled request; a complication that skipped it was the last surface that + // could freeze on its own last entry. + if (last.getNextFlipDate() <= 0 && last.isReloadAtEnd()) { + CN1WidgetProvider.requestAppRefresh(this, getKindId()); } return new ComplicationDataTimeline(current, entries); } 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 index c114490ae6c..eb855136a21 100644 --- 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 @@ -787,7 +787,13 @@ private static List children(JSONObject node) { private static String imageId(JSONObject node) { String name = node.optString("name", ""); if (name.length() > 0) { - return name; + // 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"); } return ROOT_ID + "_vec" + Integer.toHexString(node.toString().hashCode()); } 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 index 4898e6f68a8..5e11f639f5e 100644 --- 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 @@ -185,7 +185,7 @@ public void execute() throws Throwable { void buildSettingsDescribeAWatchTargetAndNotAPhoneOne() throws IOException { String props = settingsOf(watchBuilder("watchCircular")); - assertTrue(props.contains("WATCHOS_DEPLOYMENT_TARGET=10.0"), props); + 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); @@ -223,25 +223,28 @@ void liveActivitySourcesAreNeverShippedToTheWatch() throws IOException { .contains("CN1LiveActivityWidget()")); } - /// containerBackground(for:) is watchOS 10, and every generated widget applies it. Below - /// that floor the availability check around it stops compiling, so a lower target does not - /// merely lose the background -- it fails the build. + /// 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("9.0"); + 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("containerBackground"), ex.getMessage()); + assertTrue(ex.getMessage().contains("9.0"), ex.getMessage()); } - /// "10.0" orders above "9.0" only under a numeric comparison; string order says otherwise. + /// "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("10.0", watchBuilder("watchCircular").getDeploymentTarget()); + assertEquals("9.0", watchBuilder("watchCircular").getDeploymentTarget()); + watchBuilder("watchCircular").setDeploymentTarget("10.0").buildFileMap(); watchBuilder("watchCircular").setDeploymentTarget("11.2").buildFileMap(); } } From cc50dbd7cbadf069a6f327ef415a34b49e1aa103 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:23:41 +0300 Subject: [PATCH 46/96] Round twenty-seven review: ask at the end, not while building the timeline The complication's reload-at-end request read the FINAL entry, which has no flip date whether or not it is showing yet -- so a timeline published with future entries asked for a refresh immediately, hours before the moment it was for, and never again at that moment. It reads the ACTIVE entry now, which is the Tile's own rule and means what it says: the entry on the face is the last one. What it still cannot do is notice the end arriving later, and the code says so rather than implying otherwise. A complication is asked once and handed the whole timeline; the system swaps entries itself and does not come back, and UPDATE_PERIOD_SECONDS is 0 by design. A timeline with future entries is refreshed when the app next publishes, which for a push-driven surface is the normal course. The Wear manifest declares the audio service and, when remote controls are enabled, the background audio service and media-button receiver. A watch lifecycle that plays audio reaches them through the same shared implementation, and the wear module compiles them either way. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 10 +++++++++ .../wear/CN1ComplicationDataSource.java | 21 ++++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) 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 edba0bebd46..db9f8ced366 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 @@ -351,6 +351,9 @@ public File getGradleProjectDirectory() { /** Location, geofence and foreground-service declarations the Wear manifest needs too. */ private String watchFeatureComponents = ""; + + /** 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. @@ -4331,6 +4334,12 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { 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"; @@ -7668,6 +7677,7 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str + " " + watchBackgroundWorkService + " " + watchBackgroundFetchService + " " + 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. + " entries = new ArrayList(); - CN1WatchSurface.Reading last = readings.get(0); for (int i = 1; i < readings.size(); i++) { CN1WatchSurface.Reading reading = readings.get(i); ComplicationData entry = build(type, reading); @@ -149,14 +148,20 @@ private ComplicationDataTimeline buildTimeline(ComplicationType type) { new TimeInterval(Instant.ofEpochMilli(reading.getStart()), end > reading.getStart() ? Instant.ofEpochMilli(end) : Instant.MAX), entry)); - last = reading; } - // Exhausted, and the app asked to be woken when that happened. The system will hold the - // final entry indefinitely -- that is what handing it a timeline means -- and - // UPDATE_PERIOD_SECONDS is 0, so nothing else would ever ask. A widget and a Tile both - // make this throttled request; a complication that skipped it was the last surface that - // could freeze on its own last entry. - if (last.getNextFlipDate() <= 0 && last.isReloadAtEnd()) { + // 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. + // + // What this cannot do is notice the end arriving later. A complication is asked once and + // handed the whole timeline; the system then swaps entries itself and never comes back, + // and UPDATE_PERIOD_SECONDS is 0 by design. So a timeline published with future entries + // is refreshed when the app next publishes or when something asks this service again -- + // which for a push-driven surface is the normal course, and is why the widget's + // background-fetch request is the same throttled one rather than a schedule of its own. + CN1WatchSurface.Reading active = readings.get(0); + if (active.getNextFlipDate() <= 0 && active.isReloadAtEnd()) { CN1WidgetProvider.requestAppRefresh(this, getKindId()); } return new ComplicationDataTimeline(current, entries); From 5a11ac7efddf8f86fd4f5acbdcff0d6004b380c4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:02:27 +0300 Subject: [PATCH 47/96] Round twenty-eight review: advertising a watchOS the host cannot run Lowering the extension's floor to watchOS 9 last round was half a change. The extension is embedded in the watch app, and that app requires watchOS 10 for reasons of its own -- single-target WKApplication and the two-parameter onChange(of:) the generated root view uses -- so a watch on 9 cannot install the app and therefore cannot show its complication either. The extension's own floor stays at 9, which is where WidgetKit is and where it genuinely builds, but the default it is given is now the app's. A test pins the relationship rather than the number, so lowering the app's floor later needs no change here. A Tile tap covers the area the node asked for. modifiers(node) attaches the clickable to the element, 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 and taps in the remainder did nothing. Each wrapper carries the tap too; 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. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 7 +++- .../builders/WatchNativeBuilder.java | 14 +++++--- .../surfaces/wear/CN1SurfaceTileService.java | 34 ++++++++++++++++--- .../builders/WatchNativeBuilderTest.java | 17 ++++++++++ 4 files changed, 62 insertions(+), 10 deletions(-) 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 46cf6602716..1692fe83b50 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 @@ -6755,8 +6755,13 @@ private void writeWatchWidgetExtension(BuildRequest request, File distDir, File // 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", - IOSWidgetExtensionBuilder.WATCH_MIN_DEPLOYMENT_TARGET)); + WatchNativeBuilder.MIN_DEPLOYMENT_TARGET)); for (IOSWidgetExtensionBuilder.Kind kind : surfacesKinds) { watchBuilder.addKind(kind); } 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 9445e3d732e..7fc40f0a7fe 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 @@ -63,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; 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 index eb855136a21..89881249b38 100644 --- 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 @@ -480,7 +480,7 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s /// 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 static LayoutElementBuilders.LayoutElement sized( + 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); @@ -488,7 +488,8 @@ private static LayoutElementBuilders.LayoutElement sized( return element; } LayoutElementBuilders.Box.Builder box = new LayoutElementBuilders.Box.Builder() - .addContent(element); + .addContent(element) + .setModifiers(clickOnly(node)); if (w > 0) { box.setWidth(DimensionBuilders.dp(w)); } @@ -516,7 +517,7 @@ private static LayoutElementBuilders.LayoutElement sized( /// - `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 static LayoutElementBuilders.LayoutElement aligned( + private LayoutElementBuilders.LayoutElement aligned( LayoutElementBuilders.LayoutElement element, JSONObject child, boolean expandWidth, boolean expandHeight) { String align = child == null ? "" : child.optString("align", ""); @@ -540,6 +541,7 @@ private static LayoutElementBuilders.LayoutElement aligned( } LayoutElementBuilders.Box.Builder box = new LayoutElementBuilders.Box.Builder() .addContent(element) + .setModifiers(clickOnly(child)) .setHorizontalAlignment(horizontal) .setVerticalAlignment(vertical); if (expandWidth) { @@ -551,6 +553,27 @@ private static LayoutElementBuilders.LayoutElement aligned( 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) { + mods.setClickable(new ModifiersBuilders.Clickable.Builder() + .setId(action.optString("id")) + .setOnClick(launchAction(action)) + .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 @@ -561,14 +584,15 @@ private static LayoutElementBuilders.LayoutElement aligned( /// /// A weight of 0 is the default and means natural sizing, which is what the bare element /// already does. - private static LayoutElementBuilders.LayoutElement weighted( + 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); + .addContent(element) + .setModifiers(clickOnly(child)); if (horizontal) { box.setWidth(DimensionBuilders.weight(weight)); } else { 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"); + } } From fa6195a4b6e98ca5880762f1679586293d81eec0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:48:45 +0300 Subject: [PATCH 48/96] Round twenty-nine review: name the class the same way at both ends Making surfaceKindClassSuffix injective in round nineteen changed the name the builder GENERATES without changing the name the runtime LOOKS UP. AndroidSurfaceBridge.toClassSuffix still folded underscores away, so for any kind id containing one -- delivery_status, battery_level -- the provider component and the reflective complication and Tile lookups all named a class that does not exist. The publish was persisted correctly and then displayed by nothing. The runtime now records underscore positions exactly as the builder does, and a test reads the port's source and pins the two properties that make the folds agree, so the next edit to one of them fails here rather than on a device. CN1WatchSurfaceNotifier delegates, so it is covered too. Also propagate android.xintent_filter to the generated Wear launcher. The phone activity has always received it; the two halves of a companion share an applicationId and are one app to the system, so an app link or custom scheme the phone resolves has to resolve on the watch as well. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/AndroidSurfaceBridge.java | 13 ++++++++++- .../builders/AndroidGradleBuilder.java | 7 ++++++ .../AndroidWatchSurfaceCodegenTest.java | 23 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) 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 dc3527bbbde..2c7528c6364 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java @@ -218,11 +218,13 @@ public static void deliverPendingActions() { /// The identical logic lives in the Android builder's widget codegen; keep them in sync. static String toClassSuffix(String kindId) { StringBuilder sb = new StringBuilder(kindId.length()); + StringBuilder positions = new StringBuilder(); boolean upper = true; for (int i = 0; i < kindId.length(); i++) { char c = kindId.charAt(i); if (c == '_') { upper = true; + positions.append('_').append(i); continue; } if (upper) { @@ -232,7 +234,16 @@ static String toClassSuffix(String kindId) { sb.append(c); } } - return sb.toString(); + // MUST match AndroidGradleBuilder.surfaceKindClassSuffix exactly. That is where the class + // is named at build time and this is where it is found at runtime, and the two agreeing is + // the whole contract: a name computed differently here reaches a provider or a + // complication service that does not exist, and a publish is persisted and then displayed + // by nothing. + // + // The underscore POSITIONS, because folding them away is not injective -- "status" and + // "status_" would otherwise both be Status and two kinds would share one class. Absent + // for an id without underscores, so nothing an existing project publishes changes name. + return sb.append(positions).toString(); } private static ComponentName providerComponent(Context ctx, String kindId) { 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 db9f8ced366..2c5ffb56849 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 @@ -7659,6 +7659,13 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str + " \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", "") + "
\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 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 index 690f7a75d71..4341d58c527 100644 --- 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 @@ -340,6 +340,29 @@ void aMalformedExplicitVersionCodeIsRefused() { assertThrows(BuildException.class, () -> AndroidGradleBuilder.wearVersionCode(blank, 100)); } + /// The builder names the generated class and the RUNTIME finds it by name, so the two folds + /// have to agree exactly -- a name computed differently at runtime reaches a provider or a + /// complication service that does not exist, and a publish is persisted and then displayed by + /// nothing. 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 theRuntimeFoldMatchesTheBuilders() 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 String toClassSuffix(String kindId) {"); + assertTrue(at >= 0, "toClassSuffix must be there"); + String body = source.substring(at, source.indexOf("\n }\n", at)); + + // The two properties the builder's fold has, stated as the runtime has to have them. + assertTrue(body.contains("positions.append('_').append(i)"), + "the runtime must record underscore positions as the builder does:\n" + body); + assertTrue(body.contains("sb.append(positions)"), + "the runtime must append them as the builder does:\n" + body); + } + // --- tiles ------------------------------------------------------------------ /// Only the rectangular family is roomy enough for a layout rather than a readout, so it is From 9d7b11fed7588f63e67b8856384c8c74349878b3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:24:28 +0300 Subject: [PATCH 49/96] Round thirty review: answer a resources request from the descriptor A Tile host asks for resources by the version the layout it is DISPLAYING advertised, and that layout can be an entry behind. The reading each tile was built from was remembered for exactly that, but the memory is in the process 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 left a request this instance had never seen, and the fallback then answered a DIFFERENT version -- so the displayed layout's image ids were either absent from the map or mapped to artwork rasterized against the wrong state, rendering a blank or the wrong thing until the next flip. The published descriptor outlives all of it, so a miss now rebuilds from it: CN1WatchSurface.readAllEntries re-reads every entry INCLUDING the ones already superseded -- which is why readTimeline, whose whole contract is to drop the past, is not the method for this -- and the entry whose version matches is the one the host is showing. The version is a pure function of an entry, so the search cannot match the wrong one. Only a publish that replaced the descriptor outright leaves nothing to find, and that publish has already requested a tile update. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1WatchSurface.java | 47 +++++++++++++++++++ .../surfaces/wear/CN1SurfaceTileService.java | 47 ++++++++++++++++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java index 363126965c5..358887481c7 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java @@ -226,6 +226,53 @@ public static List readTimeline(Context ctx, String kindId, String fami 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(); + 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. * 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 index 89881249b38..4be8f8880a6 100644 --- 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 @@ -248,6 +248,32 @@ private static boolean hasDynamicText(JSONObject root) { * @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 String.valueOf((imageNames(reading.getLayout()).toString() + "|" + String.valueOf(reading.getState())).hashCode()); @@ -263,8 +289,22 @@ private static String resourcesVersion(CN1WatchSurface.Reading reading) { * 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. Anything else falls back to the current entry, which is the best available answer - * for a version this process has no memory of.

+ * 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 @@ -274,6 +314,9 @@ private ResourceBuilders.Resources buildResources(String requested) { 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) { From 06e75e49b002cc71303049f82e069cd0b1578a7b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:36:51 +0300 Subject: [PATCH 50/96] Round thirty-one review: stop cutting characters in half, and stop crying wolf Three fixes, two of them the same bug in different clothes. xmlize escaped a supplementary character one UTF-16 half at a time, so an emoji in a display name became �� -- a pair of surrogate code points, which are not legal XML character references. The manifest carrying one did not show the wrong glyph, it failed to parse. It now walks code points; every BMP character escapes byte for byte as before, so nothing that was already valid changes. This is repo-wide rather than watch-specific, and the phone manifest had it too. The complication's short-text cut had the same shape: substring(0, 7) on a UTF-16 index can land between the halves of one character and leave a lone high surrogate, which PlainComplicationText replaces or rejects. It now counts code points, which is also what the limit MEANS -- Wear's guidance is about characters a face can show. And registerWidgetKind treated a missing CN1Widget_ receiver as proof the kind was absent from surfaces.json, when a kind declaring only watch families has no receiver ON PURPOSE. Every correct watch-only registration printed a prominent error telling the developer to add a kind that was already there. The build-time watch-kind list settles it, and a kind in neither still reports. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/AndroidSurfaceBridge.java | 16 +++++++++++---- .../builders/AndroidGradleBuilder.java | 18 ++++++++++++----- .../wear/CN1ComplicationDataSource.java | 18 ++++++++++++++++- .../AndroidWatchSurfaceCodegenTest.java | 20 +++++++++++++++++++ 4 files changed, 62 insertions(+), 10 deletions(-) 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 2c7528c6364..ad31eacda24 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java @@ -86,10 +86,18 @@ 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); 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 2c5ffb56849..f88573e3b9c 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 @@ -7058,15 +7058,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(); } 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 index f361f08fded..87558a84021 100644 --- 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 @@ -377,11 +377,27 @@ private void reportDroppedContent(List nodes, List texts) { } } + /** + * 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 ""; } - return text.length() <= SHORT_TEXT_MAX ? text : text.substring(0, SHORT_TEXT_MAX); + 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)); } /** 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 index 4341d58c527..ad40f7f2830 100644 --- 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 @@ -363,6 +363,26 @@ void theRuntimeFoldMatchesTheBuilders() throws java.io.IOException { "the runtime must append them as the builder does:\n" + body); } + /// A display name carrying an emoji is ordinary, and escaping its UTF-16 halves separately + /// produced two surrogate character references -- which are not legal XML, so the manifest + /// did not merely show the wrong glyph, it failed to parse. No literal emoji here: these + /// sources are compiled at the platform default encoding. + @Test + void aSupplementaryCharacterEscapesAsOneReference() { + String emoji = new String(Character.toChars(0x1F600)); + assertEquals("hi 😀", AndroidGradleBuilder.xmlize("hi " + emoji)); + assertFalse(AndroidGradleBuilder.xmlize(emoji).contains("d83d"), + "a surrogate half must never reach the manifest"); + } + + /// Everything that was already valid must escape exactly as it did before. + @Test + void ordinaryCharactersEscapeUnchanged() { + assertEquals("Hello & <bye>", AndroidGradleBuilder.xmlize("Hello & ")); + assertEquals("café", AndroidGradleBuilder.xmlize("caf\u00e9")); + assertEquals("plain", AndroidGradleBuilder.xmlize("plain")); + } + // --- tiles ------------------------------------------------------------------ /// Only the rectangular family is roomy enough for a layout rather than a readout, so it is From 289bd819b4a448d7b6131f448c16b6220e03eeaa Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:44:55 +0300 Subject: [PATCH 51/96] Declare nativeSurfaceAction where the decode now lives The cn1surface deep-link decode moved out of the app delegate so the watch could reach it -- watchOS has no UIApplicationDelegate, and a complication tap was launching the watch app and then dropping the action. What did not move with it was the app delegate's include of the generated IOSSurfaceCallbacks header, so the call in IOSNative.m had no declaration and C invented one. The Catalyst leg builds with -Werror=implicit-function-declaration and failed on it, which is how this surfaced; it had been on the branch since the decode was extracted, and was mostly invisible because a following push cancels that job before it finishes. The diagnostic is not the real danger though: an invented prototype passes three JAVA_OBJECTs and a thread state through whatever registers C guesses, which links and then misbehaves. The app delegate has included this same header since before the branch, so the header is generated for every build and the include is safe. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/IOSNative.m | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index def9b220700..672296cbb82 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" From b872e76108e6114561a4a55bfee86651fd15129d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:26:14 +0300 Subject: [PATCH 52/96] Round thirty-two review: bound the Tile response, and keep the entries Four fixes. The Tile resource response had no aggregate limit. The renderer's bitmap budget is reset for every bitmap() call, so each image is measured alone and a handful of individually acceptable ones still add up past the one Binder transaction the whole response has to fit. Over that ceiling 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. The drop is logged with what to publish instead. A spacer went to the host without passing through sized(), so it was the one node whose declared setSize Tiles ignored. min is the spacer's own length, not a replacement for the shared contract, and now both apply. A complication timeline gave up entirely when the CURRENT entry could not render the requested type, even when a later one could. Nothing would ever ask again -- a complication is handed the whole timeline once and UPDATE_PERIOD_SECONDS is 0 by design -- so a RANGED_VALUE slot whose progress node appears in the next entry stayed empty for good. No-data now covers the gap until the first renderable entry, which is what the timeline's default is for; a type nothing can render still declines. And the guide claimed the Wear version-code offset is +1. It has been 100,000,000 since the review that pointed out +1 is consumed by the phone's own next release, and a developer planning explicit Play codes was being handed the collision-prone rule. Co-Authored-By: Claude Opus 5 (1M context) --- .../External-Surfaces.asciidoc | 2 +- docs/developer-guide/Wearables.asciidoc | 9 +++-- .../wear/CN1ComplicationDataSource.java | 18 +++++++--- .../surfaces/wear/CN1SurfaceTileService.java | 33 +++++++++++++++++-- 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/docs/developer-guide/External-Surfaces.asciidoc b/docs/developer-guide/External-Surfaces.asciidoc index d9abc898a06..7091edecbb8 100644 --- a/docs/developer-guide/External-Surfaces.asciidoc +++ b/docs/developer-guide/External-Surfaces.asciidoc @@ -256,7 +256,7 @@ In a desktop build the app shows a tray icon whose menu pins a floating widget p | `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` | `+1` | The Wear artifact's version code, which must outrank the phone's for Play to pick it on a watch +| `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 diff --git a/docs/developer-guide/Wearables.asciidoc b/docs/developer-guide/Wearables.asciidoc index ca4c2cc64e8..b320ecda4ce 100644 --- a/docs/developer-guide/Wearables.asciidoc +++ b/docs/developer-guide/Wearables.asciidoc @@ -449,9 +449,12 @@ 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 one, which suits the usual -numbering; `android.watchVersionCodeOffset` widens the gap and -`android.watchVersionCode` sets it outright. +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. 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 index 87558a84021..ef1949afd52 100644 --- 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 @@ -129,10 +129,14 @@ private ComplicationDataTimeline buildTimeline(ComplicationType 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 gap until the first renderable entry starts, which is exactly + // what the default in a ComplicationDataTimeline is for. ComplicationData current = build(type, readings.get(0)); - if (current == null) { - return null; - } List entries = new ArrayList(); for (int i = 1; i < readings.size(); i++) { CN1WatchSurface.Reading reading = readings.get(i); @@ -160,11 +164,17 @@ private ComplicationDataTimeline buildTimeline(ComplicationType type) { // is refreshed when the app next publishes or when something asks this service again -- // which for a push-driven surface is the normal course, and is why the widget's // background-fetch request is the same throttled one rather than a schedule of its own. + 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); if (active.getNextFlipDate() <= 0 && active.isReloadAtEnd()) { CN1WidgetProvider.requestAppRefresh(this, getKindId()); } - return new ComplicationDataTimeline(current, entries); + return new ComplicationDataTimeline(current == null ? noData() : current, entries); } @Override 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 index 4be8f8880a6..b3d2ce29b7b 100644 --- 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 @@ -83,6 +83,12 @@ public abstract class CN1SurfaceTileService extends TileService { /// 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; @@ -321,6 +327,8 @@ private ResourceBuilders.Resources buildResources(String requested) { : 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(), @@ -339,6 +347,17 @@ private ResourceBuilders.Resources buildResources(String requested) { // 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( @@ -350,6 +369,12 @@ private ResourceBuilders.Resources buildResources(String requested) { .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); @@ -421,13 +446,17 @@ private LayoutElementBuilders.LayoutElement render(JSONObject node, JSONObject s // 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. - return new LayoutElementBuilders.Box.Builder() + // 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(); + .build(), node); } if ("img".equals(type) || "vec".equals(type)) { String name = imageId(node); From 9b1006a0aa8bd7e21519554b2f66facdda6013d5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:25:40 +0300 Subject: [PATCH 53/96] Give each app its own surface scheme, and a version that cannot collide A URL scheme is a GLOBAL registration, and every Codename One app claimed the bare cn1surface. On the watch that is the whole routing story -- a complication tap carries a widgetURL and nothing else decides where it goes -- so two such apps on one watch were two claims on one name and a tap could open the other app. Any other app can also claim a known scheme and hand us whatever src and id it likes. The scheme is now cn1surface., computed in one place so the plist that registers it and the widget that generates it cannot drift, and pinned by a test. That test asserts the bare name is ABSENT from the watch plist as well, because re-adding it costs nothing at build time and hands the collision straight back. A scheme can never make a payload trusted; what this fixes is a tap landing in the wrong app, which happened with nobody being hostile. The watch registers the scheme built from the WATCH bundle id, not the phone's. The extension is built with setHostBundleId(.watchkitapp) so that is what its widgetURL carries -- I had this wrong first, and the symptom would have been a plist that reads correctly and a complication that does nothing when touched. The phone keeps accepting the bare name too: it has always registered it and something may hold a link built with it. Separately, the Tile resource version was a 32-bit String.hashCode over user-controlled text, which collides on request -- "Aa" and "BB" hash equally. Two different snapshots could advertise one version, and then Wear reads changed artwork as unchanged and rebuild() matches the wrong entry and rasterizes against the wrong state. Neither corrects itself, because both read the collision as nothing having happened. It is a truncated SHA-256 now. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/IOSNative.m | 21 ++++++++- .../com/codename1/builders/IPhoneBuilder.java | 29 +++++++++--- .../builders/WatchNativeBuilder.java | 18 +++++++- .../util/IOSWidgetExtensionBuilder.java | 45 +++++++++++++++++++ .../surfaces/ios/CN1SurfaceModel.swift | 9 +++- .../surfaces/wear/CN1SurfaceTileService.java | 38 +++++++++++++++- .../WatchWidgetExtensionTargetTest.java | 37 +++++++++++++-- 7 files changed, 179 insertions(+), 18 deletions(-) diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 672296cbb82..d53b2d0a48b 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -15358,10 +15358,27 @@ static BOOL cn1SurfacesMinOSSupported() { // 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 - || [@"cn1surface" caseInsensitiveCompare:url.scheme] != NSOrderedSame) { + 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; 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 1692fe83b50..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 @@ -7417,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); } } 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 7fc40f0a7fe..d992da9df52 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 @@ -1473,16 +1473,30 @@ void writeWatchInfoPlist(BuildRequest request, File appSrcDir) throws IOExceptio 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 cn1surface:// widgetURL and + // 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 cn1surface\n") + .append(" \n ") + .append(escapeXml(scheme)).append("\n") .append(" \n \n \n"); } if (isStandalone()) { 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 17710c0fc8f..c3f6497bb9c 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 @@ -514,9 +514,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"); 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 945bc06d211..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 @@ -272,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/wear/CN1SurfaceTileService.java b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1SurfaceTileService.java index b3d2ce29b7b..b9d76151ca8 100644 --- 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 @@ -42,6 +42,7 @@ import org.json.JSONObject; import java.io.ByteArrayOutputStream; +import java.security.MessageDigest; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -281,8 +282,41 @@ private CN1WatchSurface.Reading rebuild(String requested) { } private static String resourcesVersion(CN1WatchSurface.Reading reading) { - return String.valueOf((imageNames(reading.getLayout()).toString() - + "|" + String.valueOf(reading.getState())).hashCode()); + 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; + } } /** 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 index 00f6bc15e6d..0fbae7aef7b 100644 --- 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 @@ -34,6 +34,7 @@ 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; @@ -269,9 +270,15 @@ void noComplicationMeansNoTapPlumbing(@TempDir Path tmp) throws Exception { assertFalse(bridging.contains("cn1_watch_surface_url"), bridging); } - /// A complication supplies a cn1surface:// 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. + /// 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(); @@ -286,7 +293,29 @@ void theWatchBundleDeclaresTheSurfaceUrlScheme(@TempDir Path tmp) throws Excepti new File(srcDir, "MyApp-Watch-Info.plist").toPath()), StandardCharsets.UTF_8); assertTrue(plist.contains("CFBundleURLTypes"), plist); - assertTrue(plist.contains("cn1surface"), 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. From 825a05ea273f4f716a7a6a4433a15789ed0e0058 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:45:33 +0300 Subject: [PATCH 54/96] Stop renaming widget receivers, and stop showing stale values The injectivity fix two rounds ago renamed the generated provider of every kind whose id contains an underscore: delivery_status moved from CN1Widget_DeliveryStatus to CN1Widget_DeliveryStatus_8. Android remembers a pinned widget by its provider ComponentName, so on update the widget the user pinned named a receiver that no longer existed and the home screen dropped it. That is a real cost paid by users, silently, and it bought a collision that only two ids differing solely in underscore placement can cause. So the fold goes back to exactly what shipped, and the ambiguity is resolved at the level it belongs to -- the declared SET. The first kind claiming a folded name keeps it; a later kind that would collide takes the positional form, and the build says so, because that kind's provider is then not found under the name its id suggests. Every project that builds today gets byte-identical names. The runtime cannot recompute which kind won the plain name, since that is a property of the set. Rather than shipping the mapping and trusting two copies to agree -- the exact failure this branch already had once -- it tries both names the build can produce and takes the one that exists. An id without an underscore has one candidate, so the usual path is unchanged. Two more, both about showing something false rather than nothing: token() returned a value it had failed to persist, so a later tap read the preference, found nothing, generated a different token and rejected the app's own action; two nodes in one render could even carry different unusable ones. It returns null now and the caller leaves the action off, so the node is inert rather than broken-looking. And a timeline entry this complication type cannot render was skipped, which leaves nothing covering that interval -- and what shows then is the timeline's default, the CURRENT reading. A published timeline that moved on kept displaying the old value as though it were still current. It gets a no-data entry for its interval instead. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/AndroidSurfaceBridge.java | 70 +++++++--- .../surfaces/CN1SurfaceActionActivity.java | 26 +++- .../surfaces/CN1WatchSurfaceNotifier.java | 20 +-- .../builders/AndroidGradleBuilder.java | 127 +++++++++++++++--- .../wear/CN1ComplicationDataSource.java | 12 +- .../surfaces/wear/CN1SurfaceTileService.java | 45 +++++-- .../AndroidWatchSurfaceCodegenTest.java | 111 ++++++++------- 7 files changed, 296 insertions(+), 115 deletions(-) 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 ad31eacda24..8cbcfa3e96b 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java @@ -223,16 +223,18 @@ 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()); - StringBuilder positions = new StringBuilder(); boolean upper = true; for (int i = 0; i < kindId.length(); i++) { char c = kindId.charAt(i); if (c == '_') { upper = true; - positions.append('_').append(i); continue; } if (upper) { @@ -242,21 +244,59 @@ static String toClassSuffix(String kindId) { sb.append(c); } } - // MUST match AndroidGradleBuilder.surfaceKindClassSuffix exactly. That is where the class - // is named at build time and this is where it is found at runtime, and the two agreeing is - // the whole contract: a name computed differently here reaches a provider or a - // complication service that does not exist, and a publish is persisted and then displayed - // by nothing. - // - // The underscore POSITIONS, because folding them away is not injective -- "status" and - // "status_" would otherwise both be Status and two kinds would share one class. Absent - // for an id without underscores, so nothing an existing project publishes changes name. - return sb.append(positions).toString(); + return sb.toString(); + } + + /// The name the build falls back to when two kinds fold to one name. + /// + /// Appends where the underscores were, which is the only thing the fold discards. Mirrors + /// `AndroidGradleBuilder.surfaceKindClassSuffixDisambiguated`. + static String toDisambiguatedClassSuffix(String kindId) { + StringBuilder positions = new StringBuilder(); + for (int i = 0; i < kindId.length(); i++) { + if (kindId.charAt(i) == '_') { + positions.append('_').append(i); + } + } + return toClassSuffix(kindId) + positions; + } + + /// Every name the build could have given this kind, best first. + /// + /// The build hands a kind the plain folded name unless another declared kind already holds + /// it, in which case this one gets the positional form -- a property of the whole declared + /// set, which a runtime holding one id cannot recompute. Rather than shipping the mapping + /// and trusting two copies to agree, the caller tries the candidates and takes the one that + /// exists; that is a decision no build can drift away from. An id with no underscore folds + /// to itself, so it has exactly one candidate and nothing is tried twice. + static String[] classSuffixCandidates(String kindId) { + String plain = toClassSuffix(kindId); + String disambiguated = toDisambiguatedClassSuffix(kindId); + return plain.equals(disambiguated) ? new String[] {plain} + : new String[] {plain, disambiguated}; } private static ComponentName providerComponent(Context ctx, String kindId) { - return new ComponentName(ctx.getPackageName(), - "com.codename1.impl.android.CN1Widget_" + toClassSuffix(kindId)); + ComponentName first = null; + for (String suffix : classSuffixCandidates(kindId)) { + ComponentName candidate = new ComponentName(ctx.getPackageName(), + "com.codename1.impl.android.CN1Widget_" + suffix); + if (first == null) { + first = candidate; + } + try { + ctx.getPackageManager().getReceiverInfo(candidate, 0); + return candidate; + } catch (Exception missing) { + // Not this one. The next candidate is the disambiguated name, which the build + // uses only when another kind already holds the plain one. + continue; + } + } + // Neither exists -- a watch-only kind has no receiver at all, and registerWidgetKind + // reports a kind that is genuinely absent. The plain name is the honest answer to + // report, and broadcasting to it is the no-op it already was. + return first; } 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 4b00a73bf70..211d5683690 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceActionActivity.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceActionActivity.java @@ -67,7 +67,14 @@ public class CN1SurfaceActionActivity extends Activity { /// /// - `ctx`: any context /// - /// Returns the token, generating it on first use. + /// 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); @@ -75,7 +82,14 @@ public static synchronized String token(Context ctx) { return existing; } String fresh = new BigInteger(130, new SecureRandom()).toString(32); - prefs.edit().putString(TOKEN_KEY, fresh).commit(); + // 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; } @@ -85,7 +99,13 @@ public static synchronized String token(Context ctx) { /// - `ctx`: any context /// - `intent`: the action intent being built static void authenticate(Context ctx, Intent intent) { - intent.putExtra(EXTRA_TOKEN, token(ctx)); + 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 diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java index 0bd8118c676..e3a7371fcd3 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java @@ -58,9 +58,13 @@ public static void requestUpdate(Context ctx, String kindId) { if (ctx == null || kindId == null) { return; } - String suffix = classSuffix(kindId); - requestComplicationUpdate(ctx, "com.codename1.impl.android.CN1Complication_" + suffix); - requestTileUpdate(ctx, "com.codename1.impl.android.CN1Tile_" + suffix); + // Every name the build could have given this kind. Both requesters treat a missing + // class as "no such surface" and say nothing, so trying the candidates costs a failed + // Class.forName in the rare disambiguated case and nothing at all in the usual one. + for (String suffix : AndroidSurfaceBridge.classSuffixCandidates(kindId)) { + requestComplicationUpdate(ctx, "com.codename1.impl.android.CN1Complication_" + suffix); + requestTileUpdate(ctx, "com.codename1.impl.android.CN1Tile_" + suffix); + } } private static void requestComplicationUpdate(Context ctx, String className) { @@ -93,14 +97,4 @@ private static void requestTileUpdate(Context ctx, String className) { } } - /** - * The class-name suffix the build derives from a kind id. - * - *

Delegates to the port's own copy rather than repeating it: this and - * {@code AndroidGradleBuilder.surfaceKindClassSuffix} name the same generated class from - * opposite sides of the build, and a mismatch is a silent miss rather than an error.

- */ - private static String classSuffix(String kindId) { - return AndroidSurfaceBridge.toClassSuffix(kindId); - } } 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 f88573e3b9c..db43bcaf164 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 @@ -3471,6 +3471,31 @@ && 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)); + 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 = { @@ -3554,7 +3579,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { // iOS already refuses the same thing, so this is the two platforms agreeing. continue; } - String providerClass = "CN1Widget_" + surfaceKindClassSuffix(kindId); + 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 @@ -7085,19 +7110,25 @@ 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()); - StringBuilder positions = new StringBuilder(); boolean upper = true; for (int i = 0; i < kindId.length(); i++) { char c = kindId.charAt(i); if (c == '_') { upper = true; - positions.append('_').append(i); continue; } if (upper) { @@ -7107,17 +7138,77 @@ static String surfaceKindClassSuffix(String kindId) { sb.append(c); } } - // Folding away underscores is not injective: "status" and "status_" both read as Status, - // and two kinds sharing a suffix share a generated class -- the second overwrites the - // first and both manifest entries point at it, so one kind serves the other kind's data. - // Both ids are legal, so they have to be kept apart rather than one refused. - // - // The positions and not a count: a count separates "status" from "status_" but not - // "a__b" from "a_b_", which both discard two. Ids are [a-z][a-z0-9_]*, so the folded - // name plus where the underscores were is the whole original -- nothing else can produce - // the same pair. Absent for an id without underscores, so every name in an existing - // project is unchanged. - return sb.append(positions).toString(); + 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(); + + /** 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)) { + chosen = surfaceKindClassSuffixDisambiguated(kindId); + taken.add(chosen); + } + out.put(kindId, chosen); + } + return out; } /** @@ -7225,7 +7316,7 @@ private String generateWatchSurfaces(BuildRequest request, File srcDir, File res // watch face then shows as "A & B". String label = kind[1]; String families = kind[2]; - String suffix = surfaceKindClassSuffix(kindId); + String suffix = surfaceKindClassName(kindId); writeWatchService(surfacesDir, implDir, "CN1Complication_" + suffix, "CN1ComplicationDataSource", kindId); entries.append(complicationServiceEntry(request, "CN1Complication_" + suffix, label, 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 index ef1949afd52..c57e1247362 100644 --- 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 @@ -134,7 +134,7 @@ private ComplicationDataTimeline buildTimeline(ComplicationType type) { // 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 gap until the first renderable entry starts, which is exactly + // good. No-data covers the stretch before the first renderable entry, which is exactly // what the default in a ComplicationDataTimeline is for. ComplicationData current = build(type, readings.get(0)); List entries = new ArrayList(); @@ -142,10 +142,12 @@ private ComplicationDataTimeline buildTimeline(ComplicationType type) { CN1WatchSurface.Reading reading = readings.get(i); ComplicationData entry = build(type, reading); if (entry == null) { - // A later entry this type cannot render is skipped rather than ending the - // timeline: the entries after it may well be renderable, and the face falls back - // to the default in the gap. - continue; + // 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(); entries.add(new TimelineEntry( 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 index b9d76151ca8..ef71eca651c 100644 --- 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 @@ -672,10 +672,15 @@ 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) { - mods.setClickable(new ModifiersBuilders.Clickable.Builder() - .setId(action.optString("id")) - .setOnClick(launchAction(action)) - .build()); + // 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(); } @@ -832,10 +837,15 @@ private ModifiersBuilders.Modifiers modifiers(JSONObject node) { // 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) { - mods.setClickable(new ModifiersBuilders.Clickable.Builder() - .setId(action.optString("id")) - .setOnClick(launchAction(action)) - .build()); + // 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(); } @@ -850,16 +860,25 @@ private ModifiersBuilders.Modifiers modifiers(JSONObject node) { * 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()); - // The trampoline is exported so the tile host can start it, which means any app on the - // watch can too. This 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. activity.addKeyToExtraMapping(CN1SurfaceActionActivity.EXTRA_TOKEN, - stringExtra(CN1SurfaceActionActivity.token(this))); + stringExtra(token)); activity.addKeyToExtraMapping(CN1SurfaceActionActivity.EXTRA_SOURCE, stringExtra(getKindId())); activity.addKeyToExtraMapping(CN1SurfaceActionActivity.EXTRA_ACTION_ID, 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 index ad40f7f2830..434ee78b1f9 100644 --- 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 @@ -26,6 +26,8 @@ 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; @@ -300,22 +302,19 @@ void aVersionCodeOverPlaysCeilingIsRefused() { () -> AndroidGradleBuilder.wearVersionCode(request(), 2090000000)); } - /// The generated class name has to tell two kinds apart. Folding away underscores does not: - /// "status" and "status_" both read as Status, so the second generated class overwrote the - /// first and both manifest entries pointed at it -- one kind serving the other kind's data. - /// A count is not enough either, because "a__b" and "a_b_" discard the same number. + /// 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() { - assertEquals("Status", AndroidGradleBuilder.surfaceKindClassSuffix("status")); - assertEquals("BatteryLevel", AndroidGradleBuilder.surfaceKindClassSuffix("battery_level") - .replaceAll("_\\d+", "")); - - java.util.Set seen = new java.util.HashSet(); - String[] ids = {"status", "status_", "_status", "a_b", "ab_", "a__b", "a_b_", "_a_b"}; - for (String id : ids) { - assertTrue(seen.add(AndroidGradleBuilder.surfaceKindClassSuffix(id)), - "two ids produced the same class suffix, one of them '" + id + "'"); - } + 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 @@ -340,47 +339,63 @@ void aMalformedExplicitVersionCodeIsRefused() { assertThrows(BuildException.class, () -> AndroidGradleBuilder.wearVersionCode(blank, 100)); } - /// The builder names the generated class and the RUNTIME finds it by name, so the two folds - /// have to agree exactly -- a name computed differently at runtime reaches a provider or a - /// complication service that does not exist, and a publish is persisted and then displayed by - /// nothing. Read out of the port's source rather than called, because the port class needs an - /// Android runtime this test does not have. + /// 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 theRuntimeFoldMatchesTheBuilders() 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 String toClassSuffix(String kindId) {"); - assertTrue(at >= 0, "toClassSuffix must be there"); - String body = source.substring(at, source.indexOf("\n }\n", at)); - - // The two properties the builder's fold has, stated as the runtime has to have them. - assertTrue(body.contains("positions.append('_').append(i)"), - "the runtime must record underscore positions as the builder does:\n" + body); - assertTrue(body.contains("sb.append(positions)"), - "the runtime must append them as the builder does:\n" + body); + 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")); } - /// A display name carrying an emoji is ordinary, and escaping its UTF-16 halves separately - /// produced two surrogate character references -- which are not legal XML, so the manifest - /// did not merely show the wrong glyph, it failed to parse. No literal emoji here: these - /// sources are compiled at the platform default encoding. + /// 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 aSupplementaryCharacterEscapesAsOneReference() { - String emoji = new String(Character.toChars(0x1F600)); - assertEquals("hi 😀", AndroidGradleBuilder.xmlize("hi " + emoji)); - assertFalse(AndroidGradleBuilder.xmlize(emoji).contains("d83d"), - "a surrogate half must never reach the manifest"); + 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"); } - /// Everything that was already valid must escape exactly as it did before. + /// The runtime cannot recompute which kind won the plain name -- that is a property of the + /// whole declared set -- so it tries both names the build can produce. 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 ordinaryCharactersEscapeUnchanged() { - assertEquals("Hello & <bye>", AndroidGradleBuilder.xmlize("Hello & ")); - assertEquals("café", AndroidGradleBuilder.xmlize("caf\u00e9")); - assertEquals("plain", AndroidGradleBuilder.xmlize("plain")); + void theRuntimeTriesBothNamesTheBuildCanProduce() 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 String[] classSuffixCandidates(String kindId) {"); + assertTrue(at >= 0, "the runtime must offer both candidates"); + String body = source.substring(at, source.indexOf("\n }\n", at)); + assertTrue(body.contains("new String[] {plain, disambiguated}"), body); + assertTrue(body.contains("plain.equals(disambiguated) ? new String[] {plain}"), + "an id with no underscore has one candidate, not two:\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); } // --- tiles ------------------------------------------------------------------ From 3486f1815a1d3effc7fa198561b72998938323de Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:58:38 +0300 Subject: [PATCH 55/96] Read the kind's class name from the build, not from what exists Last round's runtime lookup tried both names the build can produce and took the one that exists. That is wrong in exactly the case it was written for: CN1Widget_Status exists because "status" declared it, so "status_" probing the plain name first finds the OTHER kind's provider. Every publish, reload and installed-count for status_ would have gone to status, and the status_ widget would sit there stale -- a worse failure than the collision this all started from, because it silently crosses two kinds. Which kind holds the plain name is a property of the whole declared set, so a runtime holding one id genuinely cannot work it out. The build states it: a generated cn1_surface_kind_classes array, written from the same table that named the classes. That is data, not a second copy of an algorithm, so there is nothing for the two sides to disagree about -- the distinction that matters after this branch already shipped one drift bug. The watch module gets its own copy, because in a companion build its resources are not the phone's. An APK built before the map existed has no such resource and falls back to the plain fold, which is what that APK was built with. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/AndroidSurfaceBridge.java | 75 ++++++++----------- .../surfaces/CN1WatchSurfaceNotifier.java | 13 ++-- .../builders/AndroidGradleBuilder.java | 46 ++++++++++++ .../AndroidWatchSurfaceCodegenTest.java | 40 +++++++--- 4 files changed, 115 insertions(+), 59 deletions(-) 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 8cbcfa3e96b..ee1156e6a61 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java @@ -247,56 +247,45 @@ static String toClassSuffix(String kindId) { return sb.toString(); } - /// The name the build falls back to when two kinds fold to one name. + /// The class-name suffix the build gave this kind. /// - /// Appends where the underscores were, which is the only thing the fold discards. Mirrors - /// `AndroidGradleBuilder.surfaceKindClassSuffixDisambiguated`. - static String toDisambiguatedClassSuffix(String kindId) { - StringBuilder positions = new StringBuilder(); - for (int i = 0; i < kindId.length(); i++) { - if (kindId.charAt(i) == '_') { - positions.append('_').append(i); + /// 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); } } - return toClassSuffix(kindId) + positions; + String mapped = kindId == null ? null : classSuffixes.get(kindId); + return mapped != null ? mapped : toClassSuffix(kindId); } - /// Every name the build could have given this kind, best first. - /// - /// The build hands a kind the plain folded name unless another declared kind already holds - /// it, in which case this one gets the positional form -- a property of the whole declared - /// set, which a runtime holding one id cannot recompute. Rather than shipping the mapping - /// and trusting two copies to agree, the caller tries the candidates and takes the one that - /// exists; that is a decision no build can drift away from. An id with no underscore folds - /// to itself, so it has exactly one candidate and nothing is tried twice. - static String[] classSuffixCandidates(String kindId) { - String plain = toClassSuffix(kindId); - String disambiguated = toDisambiguatedClassSuffix(kindId); - return plain.equals(disambiguated) ? new String[] {plain} - : new String[] {plain, disambiguated}; - } + /// 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) { - ComponentName first = null; - for (String suffix : classSuffixCandidates(kindId)) { - ComponentName candidate = new ComponentName(ctx.getPackageName(), - "com.codename1.impl.android.CN1Widget_" + suffix); - if (first == null) { - first = candidate; - } - try { - ctx.getPackageManager().getReceiverInfo(candidate, 0); - return candidate; - } catch (Exception missing) { - // Not this one. The next candidate is the disambiguated name, which the build - // uses only when another kind already holds the plain one. - continue; - } - } - // Neither exists -- a watch-only kind has no receiver at all, and registerWidgetKind - // reports a kind that is genuinely absent. The plain name is the honest answer to - // report, and broadcasting to it is the no-op it already was. - return first; + return new ComponentName(ctx.getPackageName(), + "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/CN1WatchSurfaceNotifier.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java index e3a7371fcd3..c73c9f246fd 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java @@ -58,13 +58,12 @@ public static void requestUpdate(Context ctx, String kindId) { if (ctx == null || kindId == null) { return; } - // Every name the build could have given this kind. Both requesters treat a missing - // class as "no such surface" and say nothing, so trying the candidates costs a failed - // Class.forName in the rare disambiguated case and nothing at all in the usual one. - for (String suffix : AndroidSurfaceBridge.classSuffixCandidates(kindId)) { - requestComplicationUpdate(ctx, "com.codename1.impl.android.CN1Complication_" + suffix); - requestTileUpdate(ctx, "com.codename1.impl.android.CN1Tile_" + suffix); - } + // 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); } private static void requestComplicationUpdate(Context ctx, String className) { 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 db43bcaf164..b3c5ce43f8a 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 @@ -3485,6 +3485,7 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { } } 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 @@ -7187,6 +7188,46 @@ static String surfaceKindClassSuffixDisambiguated(String kindId) { 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); @@ -7337,6 +7378,11 @@ private String generateWatchSurfaces(BuildRequest request, File srcDir, File res // 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(); 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 index 434ee78b1f9..89e52a53b4d 100644 --- 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 @@ -374,22 +374,25 @@ void acollidingKindTakesTheDisambiguatedNameAndTheFirstKeepsItsOwn() { } /// The runtime cannot recompute which kind won the plain name -- that is a property of the - /// whole declared set -- so it tries both names the build can produce. Read out of the port's - /// source rather than called, because the port class needs an Android runtime this test does - /// not have. + /// 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 theRuntimeTriesBothNamesTheBuildCanProduce() throws java.io.IOException { + 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 String[] classSuffixCandidates(String kindId) {"); - assertTrue(at >= 0, "the runtime must offer both candidates"); + 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("new String[] {plain, disambiguated}"), body); - assertTrue(body.contains("plain.equals(disambiguated) ? new String[] {plain}"), - "an id with no underscore has one candidate, not two:\n" + body); + 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) {"); @@ -398,6 +401,25 @@ void theRuntimeTriesBothNamesTheBuildCanProduce() throws java.io.IOException { "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 From d18b7120fc9bbaf66818cc3c41c870cc63c7860e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:10:46 +0300 Subject: [PATCH 56/96] Give back a transfer claim the mirror could not honour, and digest the rest A mirrored image the watch failed to store was reported with confirmTransferDelivered(..., false), which returns without touching the in-memory claim claimTransfer had already made. That claim then suppressed every retry, and since the DataItem is unchanged nothing generates a fresh callback either -- so the artwork stayed missing until the process restarted. It relinquishes the claim now, which both drops it and goes back to read the item, exactly as the tracked-delivery path beside it does when the listener never got the payload. A vector's resource id was still a 32-bit String.hashCode over the node's JSON while the resource VERSION had moved to a digest. The id is the identity of a rendered vector and imageNodes keys its map by it, so two colliding nodes lose one mapping and both elements draw the second one's artwork. While there: the complication's PendingIntent request code had the same shape. A request code is an int by API so it cannot be widened, but it can avoid the collisions short human-chosen strings actually hit -- extras are not part of filterEquals, so two complications colliding there would share one PendingIntent. The three request codes that predate this branch are left alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../wear/CN1ComplicationDataSource.java | 28 ++++++++++++++++++- .../surfaces/wear/CN1SurfaceTileService.java | 6 +++- .../wearable/CN1WearableListenerService.java | 21 ++++++++++---- 3 files changed, 48 insertions(+), 7 deletions(-) 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 index c57e1247362..630d71665d2 100644 --- 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 @@ -342,6 +342,32 @@ private Icon monochromeIcon(List nodes, JSONObject state) { 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) { @@ -353,7 +379,7 @@ private PendingIntent tapIntent(JSONObject layout) { flags |= 0x04000000; } try { - return PendingIntent.getActivity(this, intent.getDataString().hashCode(), intent, + 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); 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 index ef71eca651c..2915d107cee 100644 --- 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 @@ -944,7 +944,11 @@ private static String imageId(JSONObject node) { return name + "_" + node.optInt("w", 0) + "x" + node.optInt("h", 0) + "_" + node.optString("scale", "fit"); } - return ROOT_ID + "_vec" + Integer.toHexString(node.toString().hashCode()); + // 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) { 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 fed82c321ac..4462024b23b 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 @@ -633,11 +633,22 @@ && surfaceMirrorHandles(appPath)) { // 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. An unconfirmed transfer is redelivered, which - // is the whole point of the acknowledgement. - CN1WearableBridge.confirmTransferDelivered(this, uri, transferSeq, - surfaceMirror("receiveFile", transfer.logicalPath, - transfer.payload)); + // 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) { From 96c37b1e65f03944b32d8c41c9f25929e40f6ad7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:29:18 +0300 Subject: [PATCH 57/96] Retry a mirrored descriptor the watch could not write, and let it see packages CN1SurfaceMirror.receive caught its own write failures and returned void, so the listener could not tell a stored descriptor from a lost one and moved on either way. A Data Layer item that has not changed produces no further callback, so nothing would ever offer that descriptor again: the watch went on showing content the phone had already replaced, until the next publish. It reports now, and a failed apply is re-attempted on the same worker the transfer retries use, with the payload still in hand so no round trip is needed. Six attempts over about twenty minutes, which outlasts the transient conditions this is for -- storage momentarily full, a directory briefly unwritable -- and then says it gave up. What it deliberately does not do is persist the payload to survive the process dying mid-outage, because that means writing to the storage that just refused a write. That case waits for the phone's next publish, and the comment says so. Separately, the generated Wear manifest carried the phone's permissions but not its . That manifest is selected outright rather than merged, so nothing else supplies them, and on API 30+ an undeclared query makes resolveActivity and queryIntentActivities return filtered results. The watch module compiles the SAME sources, so code that finds a package on the phone found nothing on the watch -- which reads as a broken feature rather than a missing declaration. The daemon already had this; the two copies had drifted. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceMirror.java | 11 +++-- .../builders/AndroidGradleBuilder.java | 8 ++++ .../builders/wearable/CN1WearableBridge.java | 20 ++++++++ .../wearable/CN1WearableListenerService.java | 47 ++++++++++++++++++- 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index 1739504033e..61c5e949ebd 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -278,17 +278,20 @@ private static void sendImages(String kindId, Map images) { * @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 void receive(Context ctx, String path, byte[] payload) { + public static boolean receive(Context ctx, String path, byte[] payload) { try { String kindId = kindOf(path); if (kindId == null || payload == null) { - return; + return false; } WearableMessage message = WearableMessage.fromByteArray(path, payload); byte[] json = message.getBytes("json", null); if (json == null) { - return; + return false; } File kindDir = CN1SurfaceStore.kindDir(ctx, kindId); mkdirs(kindDir); @@ -304,8 +307,10 @@ public static void receive(Context ctx, String path, byte[] payload) { // 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; } } 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 b3c5ce43f8a..68ef1fb45c0 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 @@ -7778,6 +7778,14 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str + " \n" + 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 -- 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 685c0dc0d67..8789c1baffc 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 @@ -3519,6 +3519,26 @@ 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 + */ + 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 4462024b23b..8b7f78a5991 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 @@ -417,6 +417,43 @@ private static boolean surfaceMirrorHandles(String path) { * 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; + + /** + * 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 + */ + private void retryMirrorDescriptor(final String path, final byte[] payload, + final int attempt) { + 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 (!self.surfaceMirror("receive", path, payload)) { + self.retryMirrorDescriptor(path, payload, attempt + 1); + } + } + }, MIRROR_WRITE_RETRY_MILLIS << (attempt - 1)); + } + private boolean surfaceMirror(String method, String path, byte[] payload) { Class mirror = mirrorClass(); if (mirror == null) { @@ -555,7 +592,15 @@ && surfaceMirrorHandles(appPath)) { // deletes the descriptor first for that reason, and says so when it cannot. surfaceMirror("remove", appPath, null); } else { - surfaceMirror("receive", appPath, readMirrorPayload(event)); + 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); + } } continue; } From e52904fb8da4e90965b461756cc116bab3a24a5a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:47:14 +0300 Subject: [PATCH 58/96] Collect mirrored artwork the newest descriptor left behind The comment beside this write argued the orphan was a fixed one-publish cost. It is not, and the reason is that the two halves of a publication reach the watch by different routes: the descriptor is a Data Layer item and COLLAPSES to the newest value when delivery is delayed, while every image is a transfer with its own sequence and none of them collapse. A watch that was away for ten publications therefore receives one descriptor and then ten publications' worth of artwork behind it, with no later descriptor promised to collect the nine that are stale. Refusing the write is still the wrong answer -- images are sent before the descriptor that names them precisely so a descriptor is never live against art that has not landed, and rejecting what the stored descriptor does not name rejects exactly the art the next one is waiting for. That was tried and reverted earlier in this branch. So the sweep runs after the write and settles it by AGE rather than by reference: art still waiting for its descriptor is seconds old, art from a superseded publish is not. It runs here rather than only in receive() because the case it exists for is the one where no further descriptor arrives. The ordinary publish path keeps a zero grace, where the descriptor is written first and the reference set is authoritative at once. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceMirror.java | 30 +++++++++++++++---- .../android/surfaces/CN1SurfaceStore.java | 21 ++++++++++++- .../builders/WearableGlueCompilesTest.java | 4 +++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index 61c5e949ebd..0c6d3656b32 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -328,6 +328,13 @@ public static boolean receive(Context ctx, String path, byte[] payload) { * 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; + public static boolean receiveFile(Context ctx, String path, byte[] payload) { try { String kindId = kindOf(path); @@ -356,12 +363,16 @@ public static boolean receiveFile(Context ctx, String path, byte[] payload) { // transfer is then acknowledged and gone, and the new descriptor references a blob // that will never exist. // - // The opposite hazard -- art from publication N arriving after N+1's descriptor has - // already collected -- leaves an orphan, but a bounded one: every descriptor collects - // what it does not reference, so the next publish removes it, and only the art in - // flight during the last publish of all can linger. That is a fixed cost, not the - // unbounded growth it looks like at first glance, and it is the cheaper of the two - // failures by a wide margin. + // 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 @@ -370,6 +381,13 @@ public static boolean receiveFile(Context ctx, String path, byte[] payload) { // 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); 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 d651ee8281b..1e50e61f885 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java @@ -93,6 +93,23 @@ public static void writeWidgetTimeline(Context ctx, String kindId, String timeli /// 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"); @@ -106,10 +123,12 @@ 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/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java index efbd656ff95..fe4fa331c17 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java @@ -110,6 +110,10 @@ void theInjectedWearableGlueCompiles(@TempDir Path tmp) throws IOException { + "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" From 6969b2169d97db70fbede692749e9e264f712ca7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:13:31 +0300 Subject: [PATCH 59/96] Send complication updates one at a time, and share to the watch transferCurrentComplicationUserInfo: keeps only the MOST RECENT transfer: handing it a second payload while the first is still pending discards the first outright. Each payload carries one kind, so two watch-bearing kinds published before the first was delivered -- the ordinary case while the watch is away -- meant the earlier kind simply never arrived. The generated providers disable periodic updates by design, so nothing would have refreshed it either. They are queued per kind now and handed over one at a time, so the payload the session is holding is always one we have not yet been told was delivered. A repeat of the same kind replaces its own entry, which is right: only the newest timeline for a kind is worth sending. The queue retires a kind on failure as well as success, because a payload the watch refused will not start working by being kept at the head of the queue, and holding it there strands every kind behind it. Separately, the generated Wear manifest never declared the share receiver, though the wear module compiles that receiver and the lifecycle that handles what it delivers. ACTION_SEND and ACTION_SEND_MULTIPLE therefore could not resolve to the watch app at all, so a companion whose watch half is meant to accept shared content never appeared in the share sheet there. Both repos had this. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/CN1WatchConnectivity.m | 126 +++++++++++++++++- .../builders/AndroidGradleBuilder.java | 7 + 2 files changed, 126 insertions(+), 7 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m index f338b5fa496..edca32f37c2 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -888,6 +888,20 @@ @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; /// 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 +932,8 @@ - (instancetype)init { if (self != nil) { _pendingReplies = [[NSMutableDictionary alloc] init]; _pendingReplyAt = [[NSMutableDictionary alloc] init]; + _pendingComplications = [[NSMutableDictionary alloc] init]; + _pendingComplicationOrder = [[NSMutableArray alloc] init]; _nextInboundToken = 1; _lastReceived = [[NSMutableDictionary alloc] init]; } @@ -974,20 +990,92 @@ + (void)mirrorComplicationUserInfo:(NSDictionary *)info { NSLog(@"[CN1Surfaces] the watch complication refresh budget is spent for today; " "queueing the update to apply when the watch app next runs"); } - @try { - if (wantsWake) { - [s transferCurrentComplicationUserInfo:info]; - } else { + if (!wantsWake) { + // transferUserInfo: QUEUES -- successive calls all survive -- so it needs none of the + // sequencing below. + @try { [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); + } + return; + } + NSString *kind = [info objectForKey:@"cn1.surfaces.kind"]; + if (kind == nil) { + kind = @""; + } + [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 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; + } + WCSession *s = [self session]; + if (s == nil) { + @synchronized (self) { + _complicationInFlight = nil; + } + return; + } + @try { + [s transferCurrentComplicationUserInfo: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. + // 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]; } -#endif } +/// Retires a kind whose transfer has completed (or failed) and starts the next. +- (void)finishComplicationForKind:(NSString *)kind { + if (kind == nil) { + return; + } + @synchronized (self) { + if (_complicationInFlight != nil && [_complicationInFlight isEqualToString:kind]) { + _complicationInFlight = nil; + } + [_pendingComplications removeObjectForKey:kind]; + [_pendingComplicationOrder removeObject:kind]; + } + [self sendNextComplicationUserInfo]; +} +#endif + // --- state --------------------------------------------------------------- - (BOOL)isSupported { @@ -1386,6 +1474,30 @@ - (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; + } + if (error != nil) { + NSLog(@"[CN1Surfaces] the watch did not accept the update for \"%@\": %@", kind, + error.localizedDescription); + } + [self finishComplicationForKind:kind]; +} +#endif + - (void)session:(WCSession *)session didFinishFileTransfer:(WCSessionFileTransfer *)fileTransfer error:(NSError *)error { 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 68ef1fb45c0..2ea35ff7845 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 @@ -4433,6 +4433,13 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { + 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 From f83fefd1da312de2895c0043f3de76896c0724a1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:19:56 +0300 Subject: [PATCH 60/96] Keep a complication replacement queued behind the transfer it replaces The queue added last round retired a kind by name when its transfer finished. Publishing the same kind again while that transfer was in flight replaces the queued value with the newer timeline -- and the completion then removed it, so the newest publication was never sent and the watch stayed on the older timeline for good, the generated provider having no periodic update to fall back on. That is the same failure the queue was built to prevent, moved one step along. The payload actually handed to WCSession is now remembered and compared by IDENTITY at completion: the entry is retired only if nothing replaced it, and a replacement stays queued. It also moves to the back of the queue rather than keeping its position, so a kind republished in a tight loop cannot hold the head and starve the kinds behind it. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/CN1WatchConnectivity.m | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m index edca32f37c2..2e9a38a0390 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -902,6 +902,10 @@ @implementation CN1WatchConnectivity { 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; /// 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. @@ -1042,11 +1046,13 @@ - (void)sendNextComplicationUserInfo { return; } _complicationInFlight = kind; + _complicationInFlightPayload = info; } WCSession *s = [self session]; if (s == nil) { @synchronized (self) { _complicationInFlight = nil; + _complicationInFlightPayload = nil; } return; } @@ -1061,16 +1067,34 @@ - (void)sendNextComplicationUserInfo { } /// 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; + } + 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]; } - [_pendingComplications removeObjectForKey:kind]; - [_pendingComplicationOrder removeObject:kind]; } [self sendNextComplicationUserInfo]; } From b2f1557d176ad6b16d7dc06aaf63e288c396e2d2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:52:51 +0300 Subject: [PATCH 61/96] Match the transfer, keep staged art, cancel overtaken retries, keep the mode Four, three of them holes in work from the last few rounds. The complication completion callback matched on kind alone. The fallback transferUserInfo: path queues its own transfers for the same kinds, so one of those finishing cleared a newer complication transfer that was still in flight -- and the next publish then displaced it before the watch saw it. The transfer object WCSession hands back is remembered and compared by identity, so a completion that is not ours is ignored. The mirrored descriptor path still collected with a zero grace. The descriptor and the images are independent Data Layer items and can arrive out of order across publications, so artwork for publication B can already be staged when A's descriptor is handled -- and deleting it there is permanent, because that transfer has been acknowledged and will not be resent. It uses the same age grace the image path got. A failed descriptor write scheduled a retry that applied its payload unconditionally, so a retry could overwrite a descriptor newer than itself or resurrect a surface whose tombstone had already been processed. Each reserved path now carries a generation, bumped by every mirror event, and a retry that has been overtaken discards itself -- checked when it fires as well as when it is scheduled, since the overtaking event usually lands while it is sitting on the timer. And the generated Wear launcher took Android's default standard launch mode rather than the project's resolved one. That became load-bearing when the custom intent filter started being carried across: an app link arriving while the watch app is running would start a second stub and lifecycle, where the same configuration delivers it to the running one on the phone. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceMirror.java | 10 ++++- .../nativeSources/CN1WatchConnectivity.m | 33 +++++++++++++++- .../builders/AndroidGradleBuilder.java | 8 ++++ .../wearable/CN1WearableListenerService.java | 39 +++++++++++++++++-- 4 files changed, 85 insertions(+), 5 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index 0c6d3656b32..2827f616f09 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -301,7 +301,15 @@ public static boolean receive(Context ctx, String path, byte[] payload) { // 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. - CN1SurfaceStore.deleteUnreferencedImages(kindDir, new String(json, "UTF-8")); + // 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. diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m index 2e9a38a0390..87686cf819d 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -906,6 +906,13 @@ @implementation CN1WatchConnectivity { /// 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; /// 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. @@ -1047,17 +1054,28 @@ - (void)sendNextComplicationUserInfo { } _complicationInFlight = kind; _complicationInFlightPayload = info; + _complicationInFlightTransfer = nil; } WCSession *s = [self session]; if (s == nil) { @synchronized (self) { _complicationInFlight = nil; _complicationInFlightPayload = nil; + _complicationInFlightTransfer = nil; } return; } @try { - [s transferCurrentComplicationUserInfo:info]; + WCSessionUserInfoTransfer *handed = [s transferCurrentComplicationUserInfo:info]; + @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) { + _complicationInFlightTransfer = handed; + } + } } @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. @@ -1082,6 +1100,7 @@ - (void)finishComplicationForKind:(NSString *)kind { if (_complicationInFlight != nil && [_complicationInFlight isEqualToString:kind]) { _complicationInFlight = nil; _complicationInFlightPayload = nil; + _complicationInFlightTransfer = nil; } NSDictionary *queued = [_pendingComplications objectForKey:kind]; if (queued == nil || queued == sent) { @@ -1514,10 +1533,22 @@ - (void)session:(WCSession *)session // 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; + @synchronized (self) { + mine = _complicationInFlightTransfer != nil + && _complicationInFlightTransfer == userInfoTransfer; + } if (error != nil) { NSLog(@"[CN1Surfaces] the watch did not accept the update for \"%@\": %@", kind, error.localizedDescription); } + if (!mine) { + return; + } [self finishComplicationForKind:kind]; } #endif 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 2ea35ff7845..a49e20283ef 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 @@ -7814,6 +7814,14 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str // the first frame. + " \n" + " \n" + " \n" 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 8b7f78a5991..a775c3af4eb 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 @@ -424,6 +424,23 @@ private static boolean surfaceMirrorHandles(String path) { 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. * @@ -437,7 +454,14 @@ private static boolean surfaceMirrorHandles(String path) { * @param attempt 1 for the first re-attempt */ private void retryMirrorDescriptor(final String path, final byte[] payload, - final int attempt) { + 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 " @@ -447,8 +471,13 @@ private void retryMirrorDescriptor(final String path, final byte[] payload, 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); + self.retryMirrorDescriptor(path, payload, attempt + 1, generation); } } }, MIRROR_WRITE_RETRY_MILLIS << (attempt - 1)); @@ -585,6 +614,10 @@ && surfaceMirrorHandles(appPath)) { // 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 @@ -599,7 +632,7 @@ && surfaceMirrorHandles(appPath)) { // 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); + retryMirrorDescriptor(appPath, descriptor, 1, generation); } } continue; From 7324dc70bd441dcac64adedaea1545d70390999f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:02:53 +0300 Subject: [PATCH 62/96] Do not lose a completion that beats its own recording, or reuse a class name Two bugs of my own making, both from the last two rounds. WCSession can invoke didFinishUserInfoTransfer: before transferCurrentComplicationUserInfo: has returned to the caller, so the recorded transfer slot is briefly nil while a transfer is genuinely in flight. Last round's exact-transfer match then discarded that completion as "not ours" -- and the sending thread went on to record an already-finished transfer, leaving the kind in flight for ever and stalling the queue: every later publication silently unsent. That is worse than the coalescing it replaced. A completion arriving in that window is now PARKED, and the sending thread retires it the moment it records. The park is cleared whenever a kind is retired, so it cannot accumulate. surfaceKindClassSuffixes assumed the positional form was free once the plain name was taken. It is not: an id with no underscore has no positions to record, so its disambiguated form IS the plain name it just lost. Declare "status_" before "status" and both wanted Status -- the failed taken.add was ignored and both kinds generated the same class, so one surface served the other's data, which is exactly what the disambiguation exists to prevent. It now takes whatever is actually free. The test declares the pair in that order and fails without the fix. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/CN1WatchConnectivity.m | 44 +++++++++++++++++-- .../builders/AndroidGradleBuilder.java | 10 ++++- .../AndroidWatchSurfaceCodegenTest.java | 10 +++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m index 87686cf819d..c13b24fec29 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -913,6 +913,14 @@ @implementation CN1WatchConnectivity { /// 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. @@ -945,6 +953,7 @@ - (instancetype)init { _pendingReplyAt = [[NSMutableDictionary alloc] init]; _pendingComplications = [[NSMutableDictionary alloc] init]; _pendingComplicationOrder = [[NSMutableArray alloc] init]; + _complicationCompletedEarly = [[NSMutableSet alloc] init]; _nextInboundToken = 1; _lastReceived = [[NSMutableDictionary alloc] init]; } @@ -1067,15 +1076,30 @@ - (void)sendNextComplicationUserInfo { } @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) { - _complicationInFlightTransfer = handed; + 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. @@ -1101,6 +1125,9 @@ - (void)finishComplicationForKind:(NSString *)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) { @@ -1537,10 +1564,19 @@ - (void)session:(WCSession *)session // 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; + BOOL mine = NO; @synchronized (self) { - mine = _complicationInFlightTransfer != nil - && _complicationInFlightTransfer == userInfoTransfer; + 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, 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 a49e20283ef..2b07192e724 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 @@ -7251,8 +7251,16 @@ static Map surfaceKindClassSuffixes(List kindIds) { 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); - taken.add(chosen); + for (int n = 2; !taken.add(chosen); n++) { + chosen = surfaceKindClassSuffixDisambiguated(kindId) + "_" + n; + } } out.put(kindId, chosen); } 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 index 89e52a53b4d..12cae02c8ec 100644 --- 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 @@ -32,6 +32,7 @@ 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; @@ -371,6 +372,15 @@ void acollidingKindTakesTheDisambiguatedNameAndTheFirstKeepsItsOwn() { 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 From 4b7567e49d253a0fd895cc8516582b5f6b822480 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:32:29 +0300 Subject: [PATCH 63/96] Keep the phone's accessory families out of the watch extension A kind declaring accessoryCircular for the iPhone lock screen AND watchRectangular for the watch was admitted to the watch extension by the watch family, and then advertised .accessoryCircular there too -- so it grew a circular complication the manifest never asked for. The system already says these are not watch families: SurfaceKindFamilies excludes them from hasWatchFamily, so a kind declaring only accessoryCircular produces no watch extension at all. Letting one INTO an extension that some other family opened was the same rule answering two ways depending on what else the kind happened to declare. They are filtered on the watch target now, exactly as lockscreen already was, and this is the mirror of the rule the iOS target has had all along -- watch* families never reach the phone bundle. 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. Both halves are pinned -- the mixed kind gets only its rectangular complication, and all three watch* names still reach their accessory families. Removing the filter fails the first test. Co-Authored-By: Claude Opus 5 (1M context) --- .../util/IOSWidgetExtensionBuilder.java | 11 +++++++++ .../IOSWidgetExtensionWatchTargetTest.java | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+) 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 c3f6497bb9c..0e6d6972fa1 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 @@ -714,6 +714,17 @@ private static String mapFamily(String rawFamily, boolean watchTarget) { || "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"; 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 index 5e11f639f5e..4c336f96e51 100644 --- 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 @@ -123,6 +123,30 @@ void lockscreenIsNotAWatchFamily() throws IOException { assertFalse(swift.contains(".accessoryRectangular"), swift); } + /// 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 From 0f480b4eb34005d598575759bea68c8fbdfc03b3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:48:43 +0300 Subject: [PATCH 64/96] Let a complication tick, speak its whole value, and refuse a path for a name Three. SurfaceDynamicText was resolved to a string and handed over as plain text, so a countdown, stopwatch or relative date froze at the value from the last request -- and there is no later request, the generated provider setting no update period on purpose. The three time-RELATIVE styles are now given to the face as TimeDifferenceComplicationText, which it advances from its own clock with no wake-up. The port's own javadoc has said this is what a data source should do since it was written; this is that. time and date deliberately stay plain. They format the node's OWN timestamp -- a published moment, not the current one -- so a clock text would replace the value with whatever time it is now, a different number and a wrong one. Nothing about them moves, so nothing is lost. The long-text content description carried only the title. TalkBack reads that instead of the layout, so it announced a complication's label without the thing it actually says -- the order status, the message, the number the user wanted. It carries both now, while the visible title stays the first node. And publishRemote forwarded server-supplied image names unchanged. Those names arrive from OUTSIDE this process -- a server push or the watch mirror -- and every platform turns one into a file in the kind's directory; the iOS bridge composes 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 because a content-hash name is assumed to already hold the right bytes. Names that are not plain blob names are dropped -- not the whole publish, since a missing image renders as a gap every renderer already tolerates. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/com/codename1/surfaces/Surfaces.java | 36 +++++- .../wear/CN1ComplicationDataSource.java | 113 ++++++++++++++++-- .../data/CountDownTimeReference.javas | 5 + .../data/CountUpTimeReference.javas | 5 + .../data/TimeDifferenceComplicationText.javas | 16 +++ .../data/TimeDifferenceStyle.javas | 5 + 6 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountDownTimeReference.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountUpTimeReference.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceComplicationText.javas create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceStyle.javas diff --git a/CodenameOne/src/com/codename1/surfaces/Surfaces.java b/CodenameOne/src/com/codename1/surfaces/Surfaces.java index 3025880896f..8517723e293 100644 --- a/CodenameOne/src/com/codename1/surfaces/Surfaces.java +++ b/CodenameOne/src/com/codename1/surfaces/Surfaces.java @@ -235,8 +235,40 @@ public static void publishRemote(String kindId, String timelineJson, if (b == null || !b.areWidgetsSupported() || kindId == null || timelineJson == null) { return; } - b.publishWidgetTimeline(kindId, timelineJson, - images == null ? Collections.emptyMap() : images); + 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. + 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; + } + safe.put(name, e.getValue()); + } + return safe; } /// Asks the platform to re-render widgets from their already-published timelines. 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 index 630d71665d2..b7fb4ebe4b2 100644 --- 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 @@ -30,6 +30,8 @@ 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; @@ -39,6 +41,8 @@ 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; @@ -223,7 +227,7 @@ private ComplicationData placeholder(ComplicationType type) { // no honest placeholder to give. Null lets the picker fall back to another type. return null; } - return shortText(shorten(label), null, null); + return shortText(null, shorten(label), null, null); } /** @@ -261,14 +265,24 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi 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. + ComplicationText primary = texts.isEmpty() ? null + : textFor(firstTextNode(nodes), reading.getState(), texts.get(0)); 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 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; LongTextComplicationData.Builder builder = - new LongTextComplicationData.Builder(plain(body.length() == 0 ? title : body), - plain(title)) - .setTitle(body.length() == 0 ? null : plain(title)) + new LongTextComplicationData.Builder( + body.length() == 0 ? titleText : plain(body), plain(spoken)) + .setTitle(body.length() == 0 ? null : titleText) .setTapAction(tap); return builder.build(); } @@ -284,7 +298,7 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi new RangedValueComplicationData.Builder(value, 0f, 1f, plain(texts.isEmpty() ? getKindId() : texts.get(0))); if (!texts.isEmpty()) { - builder.setText(plain(shorten(texts.get(0)))); + builder.setText(primary != null ? primary : plain(shorten(texts.get(0)))); } builder.setTapAction(tap); return builder.build(); @@ -309,18 +323,97 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi // 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. - return shortText(texts.get(0), texts.size() > 1 ? shorten(texts.get(1)) : null, - tap); + return shortText(primary, texts.get(0), + texts.size() > 1 ? shorten(texts.get(1)) : null, tap); } return null; } - private ShortTextComplicationData shortText(String text, String title, - PendingIntent tap) { + /// The first text-bearing node, so the caller can ask what KIND of text it is. + /// + /// texts() returns resolved strings and deliberately says nothing about where they came from; + /// a native ticking value needs the node itself. + private static JSONObject firstTextNode(List nodes) { + for (JSONObject node : nodes) { + String type = node.optString("t", ""); + if ("text".equals(type) || "dyn".equals(type)) { + return node; + } + } + return 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) { + if (node != null && "dyn".equals(node.optString("t", ""))) { + ComplicationText ticking = tickingText(node, state); + 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 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 > System.currentTimeMillis() + ? 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, PendingIntent tap) { // The untruncated string becomes the content description, so a screen reader still hears // what the layout said even where the slot shows seven characters. + // + // A ticking value is handed over whole: shortening it would mean rendering it here, which + // is the freezing this exists to avoid. The face sizes what it draws. ShortTextComplicationData.Builder builder = - new ShortTextComplicationData.Builder(plain(shorten(text)), plain(text)); + new ShortTextComplicationData.Builder( + ticking != null ? ticking : plain(shorten(text)), plain(text)); if (title != null && title.length() > 0) { builder.setTitle(plain(title)); } 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/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 +} From daf299f2232337a360c855127d234dcfe051db96 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:24:45 +0300 Subject: [PATCH 65/96] Validate the remote kind, drop empty blobs, and escape the conditional key The image-name check last round guarded the wrong half of the path. The KIND is input too, and a worse one to get wrong: every platform composes it into a directory -- 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, but a descriptor from a server push or the watch mirror never passed through that check. It gets the same validator now, made package-visible rather than copied, because two spellings of one grammar is how they come to disagree. A name with no bytes also survived the filter. Android skips a null value; the iOS bridge hands it to an OutputStream and the NullPointerException escapes its IOException catch, so one attachment failing to decode aborted a publish whose timeline was otherwise fine. And the extension's ARCHS[sdk=watchos*] setting never reached Xcode. buildSettings.properties is read back with Properties.load, which splits on the first unescaped '=' -- so the key parsed as "ARCHS[sdk" and the conditional setting was simply absent, leaving the watch extension to whatever architectures the containing project supplies. Confirmed by loading the real string rather than reasoning about it. The new test asserts what the file PARSES as; the existing one, which matched the raw text, was asserting exactly the thing that looked right and parsed wrong, so it now pins the escaped form and says why. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/com/codename1/surfaces/Surfaces.java | 20 +++++++++++++++++ .../com/codename1/surfaces/WidgetKind.java | 6 ++++- .../util/IOSWidgetExtensionBuilder.java | 7 +++++- .../IOSWidgetExtensionWatchTargetTest.java | 22 ++++++++++++++++++- 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/surfaces/Surfaces.java b/CodenameOne/src/com/codename1/surfaces/Surfaces.java index 8517723e293..05cf0a61b94 100644 --- a/CodenameOne/src/com/codename1/surfaces/Surfaces.java +++ b/CodenameOne/src/com/codename1/surfaces/Surfaces.java @@ -235,6 +235,18 @@ public static void publishRemote(String kindId, String timelineJson, if (b == null || !b.areWidgetsSupported() || kindId == null || timelineJson == null) { return; } + // 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; + } b.publishWidgetTimeline(kindId, timelineJson, safeImageNames(images)); } @@ -266,6 +278,14 @@ private static Map safeImageNames(Map images) { + 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; 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/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 0e6d6972fa1..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 @@ -487,7 +487,12 @@ private String buildBuildSettings() { // 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"); - sb.append("ARCHS[sdk=watchos*]=arm64_32\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"); } 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 index 4c336f96e51..18420efa7fb 100644 --- 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 @@ -32,6 +32,7 @@ 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; @@ -123,6 +124,22 @@ void lockscreenIsNotAWatchFamily() throws IOException { 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 @@ -213,7 +230,10 @@ void buildSettingsDescribeAWatchTargetAndNotAPhoneOne() throws IOException { assertTrue(props.contains("SDKROOT=watchos"), props); assertTrue(props.contains("SUPPORTED_PLATFORMS=watchos watchsimulator"), props); assertTrue(props.contains("TARGETED_DEVICE_FAMILY=4"), props); - assertTrue(props.contains("ARCHS[sdk=watchos*]=arm64_32"), 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); } From b7b47fb98a0e286b5f19cbf950bf47d03577ebad Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:36:57 +0300 Subject: [PATCH 66/96] Let the portable families key win when it is present, not when it parses The contract says families wins outright when present, but the check was "instanceof List" -- so a malformed value fell through to iosFamilies. The one manifest carrying both keys is a manifest mid-migration, which is exactly the case where resurrecting the legacy list ships the surface the author had just replaced. Present and unusable is an authoring mistake now, named as one, with the kind id in the message. A bare string is read as a single family rather than refused: it is the obvious shorthand and the obvious way to mistype the key, and reading it the way the author plainly meant beats failing on it. The legacy key keeps its old tolerance deliberately. Nothing in the wild carries "families" yet, so tightening that costs nothing, while a manifest carrying iosFamilies predates this check and refusing one now would fail a build that has always worked. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/util/SurfaceKindFamilies.java | 47 ++++++++++++++++--- .../util/SurfaceKindFamiliesTest.java | 40 ++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) 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 index 960cedec5ad..c41b2f9a485 100644 --- 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 @@ -51,8 +51,8 @@ private SurfaceKindFamilies() { /** * The families a kind's JSON object declares. * - *

{@code families} wins outright when present; {@code iosFamilies} is the legacy - * spelling and is read only in its absence. They are not merged, because a manifest + *

{@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.

* @@ -63,12 +63,47 @@ public static List read(Map kindJson) { if (kindJson == null) { return Collections.emptyList(); } - Object declared = kindJson.get("families"); - if (!(declared instanceof List)) { - declared = kindJson.get("iosFamilies"); + Object portable = kindJson.get("families"); + if (portable != null) { + // 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. + 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 Collections.emptyList(); + return null; } List out = new ArrayList(); for (Object family : (List) declared) { 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 index 45d4783f86f..11169c58bb2 100644 --- 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 @@ -23,6 +23,7 @@ package com.codename1.util; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.Arrays; import java.util.LinkedHashMap; @@ -168,4 +169,43 @@ void nullsAreTolerated() { 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()); + } } From 8546f3a845f65ff352681d1b13a741d0b9eb0e42 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:51:15 +0300 Subject: [PATCH 67/96] Keep collecting artwork past a bad blob, and wire intents on the watch The mirror's image collection wrapped the whole enumeration in one catch, so a single unreadable PNG -- a concurrent publish removing it between the listing and the read is the ordinary way to get one -- abandoned every image after it. The descriptor then reached 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. Failures are per file now; the outer catch is left for the listing, which is the only thing that can still reach it. The generated Wear manifest also carried none of the com.codename1.intents wiring. The wear module compiles the same lifecycle, so areIntentsSupported() answers true there and publishes shortcuts aimed at CN1IntentTrampolineActivity -- an activity that manifest never declared. Both halves travel now, and they have to travel together: the static list is read from meta-data on whichever activity carries LAUNCHER, so the meta-data goes into the watch launcher and the trampoline beside it, or the shortcuts are advertised and then resolve to nothing. The placeholder complication also pre-shortened its label before handing it over as the accessibility description, which is the same mistake the published path was fixed for. Every remaining shorten() call was audited: the other four feed the visible value while the description keeps the full string. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/impl/ios/IOSSurfaceBridge.java | 22 ++++++++++++++----- .../builders/AndroidGradleBuilder.java | 14 ++++++++++++ .../wear/CN1ComplicationDataSource.java | 6 ++++- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java index dd1d56509f4..273c75f5f91 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java @@ -130,16 +130,26 @@ private Map storedImages(String kindDir) { if (name == null || !name.endsWith(".png")) { continue; } - java.io.InputStream in = fs.openInputStream(kindDir + "/" + name); + // 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 { - byte[] blob = com.codename1.io.Util.readInputStream(in); - out.put(name.substring(0, name.length() - 4), blob); - } finally { - in.close(); + 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) { - // A blob that cannot be read is a gap in the mirrored surface, not a failed reload. + // The listing itself failed, which is the only thing left that can reach here. Log.e(t); } return out; 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 2b07192e724..a4183e895da 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 @@ -351,6 +351,10 @@ public File getGradleProjectDirectory() { /** 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 = ""; @@ -3439,6 +3443,14 @@ && 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 = ""; @@ -7842,6 +7854,7 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str // 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 @@ -7859,6 +7872,7 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str + " " + watchAlarmReceiver + " " + watchBackgroundWorkService + " " + watchBackgroundFetchService + + " " + watchIntentsManifestEntries + " " + watchFeatureComponents + " " + watchMediaComponents // A complication or Tile tap still needs the trampoline, and a TILE tap needs it 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 index b7fb4ebe4b2..3c99be686ad 100644 --- 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 @@ -227,7 +227,11 @@ private ComplicationData placeholder(ComplicationType type) { // no honest placeholder to give. Null lets the picker fall back to another type. return null; } - return shortText(null, shorten(label), null, 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); } /** From 5923c47676ca7360bb66fb5f4c1063b7f0c1a403 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:04:33 +0300 Subject: [PATCH 68/96] Order the mirror by publication, not by arrival; quote and key correctly 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 one can arrive AFTER a later one sent on the prioritized one, and applying them as they land let the older timeline overwrite the newer -- permanently, the generated provider having no periodic update to correct it. Every payload now carries a publication sequence, seeded from the wall clock so it keeps rising across a relaunch, and the watch ignores anything at or below what it has already applied. Persisted rather than held in memory, because that delegate runs in a process the system starts and stops at will and a counter that reset would let the next stale payload through. The lean payload carries it too, or a publication that shed its imagery would arrive unordered. "families": null is PRESENT. The presence fix last round tested the retrieved value, so an explicit null still fell through to iosFamilies -- the same resurrection through a different door. It asks containsKey now, and an explicit null means the kind declares none. And the generated extension settings went into a DOUBLE-quoted Ruby literal while being escaped for a single-quoted one. A provisioning profile named Acme "Watch" closed the literal and made the project script fail to parse, and a name containing #{ would have been interpolated. Both sides are single-quoted now, which is exactly the alphabet the escaper handles. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/CN1WatchConnectivity.m | 23 +++++++++++++ Ports/iOSPort/nativeSources/IOSNative.m | 34 +++++++++++++++++++ .../builders/WatchNativeBuilder.java | 9 +++-- .../codename1/util/SurfaceKindFamilies.java | 12 +++++-- .../util/SurfaceKindFamiliesTest.java | 14 ++++++++ 5 files changed, 88 insertions(+), 4 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m index c13b24fec29..071018039f6 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -1717,6 +1717,29 @@ - (void)applyMirroredSurface:(NSDictionary *)info { 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"]; + if ([sequence isKindOfClass:[NSNumber class]]) { + NSString *key = [@"cn1.surfaces.seq." stringByAppendingString:kind]; + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + long long applied = (long long)[defaults doubleForKey:key]; + long long 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; + } + [defaults setDouble:(double)incoming forKey:key]; + } NSMutableArray *names = [NSMutableArray array]; NSMutableArray *blobs = [NSMutableArray array]; for (NSString *key in info) { diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index d53b2d0a48b..8cc18812365 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -15608,6 +15608,26 @@ void com_codename1_impl_ios_IOSNative_surfacesEndActivity___java_lang_String_jav #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) { + long long now = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); + last = now > last ? now : last + 1; + 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) @@ -15669,6 +15689,17 @@ void com_codename1_impl_ios_IOSNative_surfacesMirrorToWatch___java_lang_String_j 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 @@ -15700,6 +15731,9 @@ void com_codename1_impl_ios_IOSNative_surfacesMirrorToWatch___java_lang_String_j // 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]; 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 d992da9df52..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 @@ -3520,9 +3520,14 @@ private void appendWidgetExtension(StringBuilder s, File tmpFile, String resolve 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"); + .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/util/SurfaceKindFamilies.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/SurfaceKindFamilies.java index c41b2f9a485..6fab0a73ca5 100644 --- 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 @@ -63,13 +63,21 @@ public static List read(Map kindJson) { if (kindJson == null) { return Collections.emptyList(); } - Object portable = kindJson.get("families"); - if (portable != null) { + // 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. + if (portable == null) { + // Explicitly nothing. Not an error -- it is a legible way to say the kind + // declares no families -- but it must not fall through to iosFamilies. + return Collections.emptyList(); + } List read = asFamilyList(portable); if (read == null) { throw new IllegalArgumentException("The \"families\" value of surface kind \"" 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 index 11169c58bb2..e5fda666ae1 100644 --- 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 @@ -208,4 +208,18 @@ void aMalformedLegacyValueStillDegradesQuietly() { assertTrue(SurfaceKindFamilies.read(kind).isEmpty()); } + + /// An explicit null is PRESENT. A JSON author writes "families": null to mean the kind + /// declares none, and reading iosFamilies instead resurrects exactly what they were removing + /// -- the same failure as a malformed value, reached through a different door. + @Test + void anExplicitNullFamiliesKeyStillWins() { + Map kind = new LinkedHashMap(); + kind.put("id", "status"); + kind.put("families", null); + kind.put("iosFamilies", Arrays.asList("small")); + + assertTrue(SurfaceKindFamilies.read(kind).isEmpty(), + "the legacy list must not come back through a null portable key"); + } } From 08ff1a0e19683a3ba7638d2e2a35b4ab99489e53 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:13:57 +0300 Subject: [PATCH 69/96] Remember the highest mirror sequence we issued, not just 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 handed out numbers below the high-water mark the WATCH has persisted, and the watch rejects those by design, so every mirrored update would be dropped until the clock caught up: hours, or days. The sender now remembers what it issued and resumes from there, which makes the sequence monotonic across a restart whatever the clock does. It is the same NSUserDefaults the receiver already uses for its own mark, so both ends survive the process death the system inflicts on them. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/IOSNative.m | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 8cc18812365..b1fdf243610 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -15621,8 +15621,21 @@ static long long cn1NextSurfaceMirrorSequence(void) { 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; } } From 126fd0bee89b77882e009a91a516ee9e2dd1fbee Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:22:36 +0300 Subject: [PATCH 70/96] Retry a mirrored withdrawal the watch could not carry out CN1SurfaceMirror.remove returned void, so a failed withdrawal was indistinguishable from a completed one and the tombstone was consumed either way. A Data Layer deletion is offered exactly once -- unlike a changed item there is nothing left to ask for -- so a directory that was momentarily unwritable left the complication showing content the phone had withdrawn, permanently. remove reports now, and a false answer is retried on the same worker and under the same generation guard as a failed descriptor write, so a republish landing meanwhile cancels the withdrawal rather than racing it. This needed the sharper treatment of the two: a descriptor that fails to apply is at least offered again by the next publish, and a deletion never is. The reflective helper was returning true for remove regardless, which is what made the void form look successful; it reads the answer now, and a port still declaring the void form answers null there and is treated as successful exactly as before. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceMirror.java | 17 ++++-- .../wearable/CN1WearableListenerService.java | 59 +++++++++++++++++-- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index 2827f616f09..a94cec9b0f1 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -420,12 +420,14 @@ public static boolean receiveFile(Context ctx, String path, byte[] payload) { * * @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 void remove(Context ctx, String path) { + public static boolean remove(Context ctx, String path) { try { String kindId = kindOf(path); if (kindId == null) { - return; + 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 @@ -439,9 +441,12 @@ public static void remove(Context ctx, String path) { // the phone withdrew. File timeline = new File(kindDir, "timeline.json"); if (timeline.exists() && !timeline.delete()) { - Log.w(TAG, "Could not delete " + timeline + ", so the watch may keep showing a " - + "surface the phone withdrew. The deletion cannot be redelivered; the " - + "next publish of " + kindId + " will replace it."); + // 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) { @@ -455,8 +460,10 @@ public static void remove(Context ctx, String path) { 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; } } 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 a775c3af4eb..966e65abe72 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 @@ -453,6 +453,41 @@ private static synchronized long mirrorGeneration(String 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) { @@ -490,9 +525,15 @@ private boolean surfaceMirror(String method, String path, byte[] payload) { } try { if (payload == null && "remove".equals(method)) { - mirror.getMethod(method, android.content.Context.class, String.class) + Object removed = mirror + .getMethod(method, android.content.Context.class, String.class) .invoke(null, this, path); - return true; + // 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) @@ -621,9 +662,17 @@ && surfaceMirrorHandles(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. The mirror - // deletes the descriptor first for that reason, and says so when it cannot. - surfaceMirror("remove", appPath, null); + // 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)) { From 9f5f272e4d91f11c335e0b1f81f071fafe60f456 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:23:56 +0300 Subject: [PATCH 71/96] Move the mirror's high-water mark only when the timeline is installed The sequence was recorded before the write, so an App Group that was momentarily unwritable still raised the mark: the payload was consumed, and a redelivery of the very same publication was then rejected as superseded. The complication kept its old content until the phone happened to publish again -- which is the staleness the sequence was added to prevent, reached through the ordering rather than through the transports. cn1_watch_apply_mirrored_surface reports whether it installed anything -- it already had three early returns for exactly these failures and simply did not say so -- and the mark moves only after a success. A failed apply leaves it where it was, so the next delivery of this publication, or any later one, still lands. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/CN1WatchConnectivity.m | 22 ++++++++++++++----- .../CodenameOne_GLViewController.h | 2 +- Ports/iOSPort/nativeSources/IOSNative.m | 11 +++++----- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m index 071018039f6..a9fde68ec7e 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -1728,17 +1728,18 @@ - (void)applyMirroredSurface:(NSDictionary *)info { // 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]]) { - NSString *key = [@"cn1.surfaces.seq." stringByAppendingString:kind]; + sequenceKey = [@"cn1.surfaces.seq." stringByAppendingString:kind]; NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; - long long applied = (long long)[defaults doubleForKey:key]; - long long incoming = [(NSNumber *)sequence longLongValue]; + 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; } - [defaults setDouble:(double)incoming forKey:key]; } NSMutableArray *names = [NSMutableArray array]; NSMutableArray *blobs = [NSMutableArray array]; @@ -1751,7 +1752,18 @@ - (void)applyMirroredSurface:(NSDictionary *)info { } } } - cn1_watch_apply_mirrored_surface(kind, json, names, blobs); + // 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)) { + return; + } + if (sequenceKey != nil) { + [[NSUserDefaults standardUserDefaults] setDouble:(double)incoming forKey:sequenceKey]; + } } #else - (void)applyMirroredSurface:(NSDictionary *)info { diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index b800153db7d..02370cb5ec0 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -195,7 +195,7 @@ BOOL cn1HandleSurfaceURL(NSURL *url); // 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. -void cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, +BOOL cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, NSArray *imageNames, NSArray *imageBlobs); #endif #endif diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index b1fdf243610..c919fec5328 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -15794,11 +15794,11 @@ void com_codename1_impl_ios_IOSNative_surfacesMirrorToWatch___java_lang_String_j // // The layout matches what IOSSurfaceBridge writes locally, because the extension reads one // format and does not care which side produced it. -void cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, +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; + return NO; } NSString *kindDir = [[container stringByAppendingPathComponent:@"cn1surfaces"] stringByAppendingPathComponent:kind]; @@ -15807,7 +15807,7 @@ void cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, if (![fm createDirectoryAtPath:kindDir withIntermediateDirectories:YES attributes:nil error:&err]) { NSLog(@"[CN1Surfaces] could not prepare the mirrored surface directory: %@", err); - return; + 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. @@ -15830,13 +15830,13 @@ void cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, // reload sends the whole set again. NSLog(@"[CN1Surfaces] could not store mirrored image \"%@\" for \"%@\"; keeping the " "previous timeline", name, kind); - return; + return NO; } } if (![json writeToFile:[kindDir stringByAppendingPathComponent:@"timeline.json"] atomically:YES]) { NSLog(@"[CN1Surfaces] could not write the mirrored timeline for \"%@\"", kind); - return; + 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 @@ -15874,6 +15874,7 @@ void cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, ((void (*)(id, SEL, NSString *))objc_msgSend)((id)bridge, NSSelectorFromString(@"reloadTimelines:"), kind); } + return YES; } #endif From 88c8ac02c394b9f4a3182fe39a393850a7ee70e7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:35:31 +0300 Subject: [PATCH 72/96] Ask for the reload at the only moment we get, and retry a failed install reload-at-end asked for a background fetch only when the timeline was ALREADY exhausted -- which is never true during the one request a complication gets. A timeline published with future entries is exactly the one that needs the request, and it was the one that never made it: Wear swaps to the final entry itself, UPDATE_PERIOD_SECONDS is 0 by design, and nothing calls the provider again, so that value stood for ever. The request now goes out whenever the timeline reloads at its end. Asking early is safe -- it is throttled and a no-op for an app with no background-fetch listener, the same treatment the widget path gives it -- and the cost of asking early is one fetch against a complication frozen for good. A mirrored surface the watch could not install is also retried now. didReceiveUserInfo is a one-shot delivery, so leaving the sequence mark alone merely permits a LATER publication; it does not bring this one back, and if the phone publishes nothing further the old content stays. Bounded and in memory, the same shape the Android mirror uses, for the same reason: the condition is transient, and persisting the payload would mean writing to the storage that just refused a write. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/CN1WatchConnectivity.m | 35 +++++++++++++++++++ .../wear/CN1ComplicationDataSource.java | 19 ++++++---- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m index a9fde68ec7e..0630fb5415f 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -1711,7 +1711,32 @@ - (void)session:(WCSession *)session didReceiveUserInfo:(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; + } + __block NSDictionary *payload = info; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(CN1_MIRROR_APPLY_DELAY_NS << (attempt - 1))), + dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [self applyMirroredSurface:payload attempt:attempt + 1]; + }); +} + - (void)applyMirroredSurface:(NSDictionary *)info { + [self applyMirroredSurface:info 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]]) { @@ -1759,6 +1784,16 @@ - (void)applyMirroredSurface:(NSDictionary *)info { // 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) { 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 index 3c99be686ad..9b3136183cb 100644 --- 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 @@ -164,12 +164,17 @@ private ComplicationDataTimeline buildTimeline(ComplicationType type) { // 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. // - // What this cannot do is notice the end arriving later. A complication is asked once and - // handed the whole timeline; the system then swaps entries itself and never comes back, - // and UPDATE_PERIOD_SECONDS is 0 by design. So a timeline published with future entries - // is refreshed when the app next publishes or when something asks this service again -- - // which for a push-driven surface is the normal course, and is why the widget's - // background-fetch request is the same throttled one rather than a schedule of its own. + // 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 @@ -177,7 +182,7 @@ private ComplicationDataTimeline buildTimeline(ComplicationType type) { return null; } CN1WatchSurface.Reading active = readings.get(0); - if (active.getNextFlipDate() <= 0 && active.isReloadAtEnd()) { + if (active.isReloadAtEnd()) { CN1WidgetProvider.requestAppRefresh(this, getKindId()); } return new ComplicationDataTimeline(current == null ? noData() : current, entries); From afd5c6cc2b56a8f8ca8dec931c64b13a70155c02 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:43:08 +0300 Subject: [PATCH 73/96] Retain and serialize the mirror retry Two faults in the retry added last round, both from treating this file as if it were ARC. It is not -- the retain and release calls throughout are the giveaway, and my syntax probe had been passing -fobjc-arc, which is the wrong memory model and hid the first of these entirely. A copied block retains an ordinary captured object under manual reference counting; it does not retain a __block one. The payload was therefore released when didReceiveUserInfo returned, and the retry read freed memory twenty seconds later. Captured plain now. The retry also ran on a global queue, so it could execute alongside a freshly delivered publication -- and the check-install-record sequence in applyMirroredSurface is not atomic across the three. A retry could pass the sequence check, let the newer publication install and record its mark, then overwrite it and LOWER the mark, leaving the complication stale and the ordering permanently confused. Every apply, delivered or retried, now runs on one serial queue. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/CN1WatchConnectivity.m | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m index 0630fb5415f..2a04fdc9676 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -1711,6 +1711,19 @@ - (void)session:(WCSession *)session didReceiveUserInfo:(NSDictionary *)info attempt:(int)a "keeps what it had until the phone publishes again", CN1_MIRROR_APPLY_RETRIES); return; } - __block NSDictionary *payload = info; + // 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))), - dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + cn1MirrorQueue(), ^{ [self applyMirroredSurface:payload attempt:attempt + 1]; }); } - (void)applyMirroredSurface:(NSDictionary *)info { - [self applyMirroredSurface:info attempt:1]; + // 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 { From aa7af9fee2c2bede02f7aeea82f5abf8523b1590 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:51:24 +0300 Subject: [PATCH 74/96] Let a relative complication cross its own target A relative node counting down to a future moment was given a CountDownTimeReference, and the direction is fixed by the reference type -- so once the target passed, "in 5m" could never become "5m ago", which is what formatRelative produces everywhere else. Nothing would rebuild it either: the provider is asked once and sets no update period. The timeline is the mechanism for exactly this, and it was already being built. A second entry now starts at the target and carries the count-up form, composed as the moment AFTER the crossing rather than as now -- which is why the build path takes a stated moment instead of reading the clock. It runs until the next timeline flip, or without end when there is none. Only a relative node crosses. A timer counts one way by definition, and a clock or a date does not move at all; a target already in the past is counting up from the start and never changes side again. Co-Authored-By: Claude Opus 5 (1M context) --- .../wear/CN1ComplicationDataSource.java | 60 +++++++++++++++++-- 1 file changed, 55 insertions(+), 5 deletions(-) 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 index 9b3136183cb..a186800c419 100644 --- 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 @@ -182,6 +182,23 @@ private ComplicationDataTimeline buildTimeline(ComplicationType type) { return null; } CN1WatchSurface.Reading active = readings.get(0); + // A relative countdown has to change SIDES when it reaches its target: "in 5m" becomes + // "5m ago", which is what formatRelative does everywhere else. The direction is fixed by + // the reference type handed to TimeDifferenceComplicationText, so one text cannot span + // both -- and nothing would rebuild it, the provider being asked once with no update + // period. The timeline is the mechanism for exactly this: a second entry starting at the + // target, composed as the moment after it, carries the count-up form. + long crossing = relativeCrossing(active); + if (crossing > 0) { + ComplicationData after = build(type, active, crossing + 1); + if (after != null) { + long until = active.getNextFlipDate(); + entries.add(new TimelineEntry( + new TimeInterval(Instant.ofEpochMilli(crossing), + until > crossing ? Instant.ofEpochMilli(until) : Instant.MAX), + after)); + } + } if (active.isReloadAtEnd()) { CN1WidgetProvider.requestAppRefresh(this, getKindId()); } @@ -265,6 +282,15 @@ private static String familyFor(ComplicationType type) { } 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) { @@ -278,7 +304,7 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi // 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. ComplicationText primary = texts.isEmpty() ? null - : textFor(firstTextNode(nodes), reading.getState(), texts.get(0)); + : textFor(firstTextNode(nodes), reading.getState(), texts.get(0), asOf); if (ComplicationType.LONG_TEXT.equals(type)) { String title = texts.isEmpty() ? getKindId() : texts.get(0); @@ -338,6 +364,29 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi return null; } + /** + * When this reading's primary text stops counting down and starts counting up, or 0. + * + *

Only a {@code relative} node crosses: a timer counts one way by definition, and a clock + * or a date does not move at all. Only a target still in the FUTURE matters -- one already + * past is counting up from the start and never changes side again.

+ * + * @param reading the entry being rendered + * @return the crossing moment in epoch millis, or 0 when nothing crosses + */ + private long relativeCrossing(CN1WatchSurface.Reading reading) { + if (reading == null) { + return 0; + } + JSONObject node = firstTextNode(CN1WatchSurface.flatten(reading.getLayout())); + 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 first text-bearing node, so the caller can ask what KIND of text it is. /// /// texts() returns resolved strings and deliberately says nothing about where they came from; @@ -354,9 +403,10 @@ private static JSONObject firstTextNode(List nodes) { /// 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) { + private ComplicationText textFor(JSONObject node, JSONObject state, String resolved, + long asOf) { if (node != null && "dyn".equals(node.optString("t", ""))) { - ComplicationText ticking = tickingText(node, state); + ComplicationText ticking = tickingText(node, state, asOf); if (ticking != null) { return ticking; } @@ -380,7 +430,7 @@ private ComplicationText textFor(JSONObject node, JSONObject state, String resol * @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) { + 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; @@ -390,7 +440,7 @@ private ComplicationText tickingText(JSONObject node, JSONObject state) { 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 > System.currentTimeMillis() + return date > asOf ? new TimeDifferenceComplicationText.Builder( TimeDifferenceStyle.SHORT_SINGLE_UNIT, new CountDownTimeReference(at)).build() From 5bb898e8723af29870be375d5869317b941986a3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:00:18 +0300 Subject: [PATCH 75/96] Tick the long-text body too, and keep a crossing inside its own entry Two faults in last round's crossing work. Only the first text node was handed over as ticking text, but a rectangular layout routinely puts a static label first and the moving value beneath it -- so a countdown in the body froze, which is exactly what the ticking work existed to prevent, one node along. The body ticks now when it is exactly one node; a join of several stays plain, there being nothing to hand a face that would advance part of a string. relativeCrossing considers both nodes and takes the earlier, since that is when the timeline first differs. The crossing entry could also outlive the reading it belongs to. When the target falls at or after the next flip, the interval ran to Instant.MAX and overlapped every later entry -- a reading that had stopped being current competing with the ones that replaced it. It is only emitted inside the active reading's own window now; the entry that is active at the crossing computes its own when it is built. Co-Authored-By: Claude Opus 5 (1M context) --- .../wear/CN1ComplicationDataSource.java | 51 +++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) 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 index a186800c419..6096be0e727 100644 --- 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 @@ -189,10 +189,15 @@ private ComplicationDataTimeline buildTimeline(ComplicationType type) { // period. The timeline is the mechanism for exactly this: a second entry starting at the // target, composed as the moment after it, carries the count-up form. long crossing = relativeCrossing(active); - if (crossing > 0) { + long until = active.getNextFlipDate(); + // Only INSIDE the active reading's own window. A crossing at or after the next flip + // belongs to an entry this one has already handed over to, and an interval running to + // Instant.MAX from there would overlap every later entry and compete with them -- a stale + // reading resurfacing after it stopped being current. The entry that is active then will + // compute its own crossing when it is built. + if (crossing > 0 && (until <= 0 || crossing < until)) { ComplicationData after = build(type, active, crossing + 1); if (after != null) { - long until = active.getNextFlipDate(); entries.add(new TimelineEntry( new TimeInterval(Instant.ofEpochMilli(crossing), until > crossing ? Instant.ofEpochMilli(until) : Instant.MAX), @@ -310,13 +315,20 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi 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. + ComplicationText bodyText = texts.size() == 2 + ? textFor(secondTextNode(nodes), reading.getState(), texts.get(1), asOf) + : plain(body); // 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; LongTextComplicationData.Builder builder = new LongTextComplicationData.Builder( - body.length() == 0 ? titleText : plain(body), plain(spoken)) + body.length() == 0 ? titleText : bodyText, plain(spoken)) .setTitle(body.length() == 0 ? null : titleText) .setTapAction(tap); return builder.build(); @@ -378,7 +390,23 @@ private long relativeCrossing(CN1WatchSurface.Reading reading) { if (reading == null) { return 0; } - JSONObject node = firstTextNode(CN1WatchSurface.flatten(reading.getLayout())); + List nodes = CN1WatchSurface.flatten(reading.getLayout()); + // BOTH nodes that can be handed over as ticking text, not only the first: a rectangular + // layout routinely puts the label first and the moving value beneath it, and the body + // ticks too. The earliest crossing wins, since that is when the timeline first differs. + long first = relativeCrossingOf(firstTextNode(nodes), reading); + long second = relativeCrossingOf(secondTextNode(nodes), reading); + if (first <= 0) { + return second; + } + if (second <= 0) { + return first; + } + return Math.min(first, second); + } + + /// 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; @@ -387,6 +415,21 @@ private long relativeCrossing(CN1WatchSurface.Reading reading) { return date > System.currentTimeMillis() ? date : 0; } + /// The second text-bearing node, which is a long-text body when there is exactly one. + private static JSONObject secondTextNode(List nodes) { + boolean seenFirst = false; + for (JSONObject node : nodes) { + String type = node.optString("t", ""); + if ("text".equals(type) || "dyn".equals(type)) { + if (seenFirst) { + return node; + } + seenFirst = true; + } + } + return null; + } + /// The first text-bearing node, so the caller can ask what KIND of text it is. /// /// texts() returns resolved strings and deliberately says nothing about where they came from; From aede072cc2a4847527779090889a985cb1eaa9bf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:06:15 +0300 Subject: [PATCH 76/96] Render a future entry as of when it takes over, and name the phone APK A future timeline entry was rendered against the request's clock rather than against the moment it becomes current. An interval-based progress scheduled for an hour out was therefore frozen at today's fraction, and a relative value that has already crossed by then was built as a countdown -- and both stay wrong, the provider being asked once with no update period. Each entry is now built as of its own start, which is why resolveFraction and progressValue take a moment instead of reading the clock; the active entry passes now, as it should. And the Android build script published whichever debug APK the filesystem walked into first. A companion build assembles two application modules, and traversal order is not a statement about which artifact is the product -- callers install what this reports, so it could hand them the watch-only APK. It names the phone module explicitly, with the old search kept as a fallback for a project whose module is not called app, minus anything under a wear module, which is never the phone artifact. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceRenderer.java | 14 ++++++++++++-- .../impl/android/surfaces/CN1WatchSurface.java | 18 +++++++++++++++++- .../wear/CN1ComplicationDataSource.java | 11 ++++++++--- .../builders/WearGlueCompilesTest.java | 4 ++++ scripts/build-android-app.sh | 12 +++++++++++- 5 files changed, 52 insertions(+), 7 deletions(-) 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 dba7d0ef938..a692d98e6c5 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java @@ -963,6 +963,17 @@ private static double resolveFraction(JSONObject node, RenderContext rc) { /// 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 && state != null @@ -973,8 +984,7 @@ static double resolveFraction(JSONObject node, JSONObject state) { // 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/CN1WatchSurface.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java index 358887481c7..e238f02be84 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java @@ -472,6 +472,22 @@ public static long dynamicDate(JSONObject node, JSONObject 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; } @@ -482,7 +498,7 @@ public static float progressValue(JSONObject prog, JSONObject state) { && !(prog.has("start") && prog.has("end"))) { return -1f; } - return (float) CN1SurfaceRenderer.resolveFraction(prog, state); + return (float) CN1SurfaceRenderer.resolveFraction(prog, state, asOf); } /** 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 index 6096be0e727..cf33d82d2c5 100644 --- 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 @@ -140,11 +140,16 @@ private ComplicationDataTimeline buildTimeline(ComplicationType type) { // 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. - ComplicationData current = build(type, readings.get(0)); + 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); - ComplicationData entry = build(type, reading); + // 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 @@ -335,7 +340,7 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi } if (ComplicationType.RANGED_VALUE.equals(type)) { JSONObject prog = CN1WatchSurface.firstOfType(nodes, "prog"); - float value = CN1WatchSurface.progressValue(prog, reading.getState()); + 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. 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 index 4e0a9af3a14..2f5e6447cbf 100644 --- 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 @@ -126,6 +126,10 @@ void theInjectedWearServicesCompile(@TempDir Path tmp) throws IOException { + "{ 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")); diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh index 70e6b5b8315..afd186e96e7 100755 --- a/scripts/build-android-app.sh +++ b/scripts/build-android-app.sh @@ -235,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" From adc3c0921e5886fd075effe7e656f7e67aac21e1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:19:05 +0300 Subject: [PATCH 77/96] Point the watch's push glue at the watch stub, and cross every relative value 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
Stub substituted in -- an activity the Wear manifest does not declare -- and the wear module compiled that same copy, 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. The wear source set excludes the shared StubUtil now and a watch-specific one is written beside the watch stub; two copies of one class could not compile, which is why it is an exclusion rather than an addition. Crossings were also generated only for the ACTIVE reading and only for its earliest node. A future entry with a relative target inside its own window took over as a countdown and stayed one past its target, and a layout with two relative values scheduled the first and left the second counting down through its own. Every reading gets its crossings now, every ticking node contributes one, and each runs until the next crossing rather than to the end of the window. And a SHORT_TEXT title was announced to nobody: it is displayed, but the content description carried only the primary text -- and the caller had already shortened the title, so there was no full value left to describe with. The title now arrives whole and is shortened where its visual form is made. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 50 ++++++++ .../wear/CN1ComplicationDataSource.java | 108 ++++++++++-------- 2 files changed, 110 insertions(+), 48 deletions(-) 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 a4183e895da..3063367060b 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 @@ -7780,6 +7780,7 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str } 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 + ")"); @@ -8084,6 +8085,47 @@ private void copyMobileServiceConfig(File studioProjectDir, File wearDir) * @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"; + 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 @@ -8109,6 +8151,14 @@ static String deriveWearGradle(String appGradle, int intVersion, int wearVersion 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. + + " java.exclude '**/com/codename1/impl/android/StubUtil.java'\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 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 index cf33d82d2c5..a06dcf9f1b4 100644 --- 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 @@ -163,6 +163,11 @@ private ComplicationDataTimeline buildTimeline(ComplicationType type) { new TimeInterval(Instant.ofEpochMilli(reading.getStart()), end > reading.getStart() ? Instant.ofEpochMilli(end) : 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 @@ -187,28 +192,7 @@ private ComplicationDataTimeline buildTimeline(ComplicationType type) { return null; } CN1WatchSurface.Reading active = readings.get(0); - // A relative countdown has to change SIDES when it reaches its target: "in 5m" becomes - // "5m ago", which is what formatRelative does everywhere else. The direction is fixed by - // the reference type handed to TimeDifferenceComplicationText, so one text cannot span - // both -- and nothing would rebuild it, the provider being asked once with no update - // period. The timeline is the mechanism for exactly this: a second entry starting at the - // target, composed as the moment after it, carries the count-up form. - long crossing = relativeCrossing(active); - long until = active.getNextFlipDate(); - // Only INSIDE the active reading's own window. A crossing at or after the next flip - // belongs to an entry this one has already handed over to, and an interval running to - // Instant.MAX from there would overlap every later entry and compete with them -- a stale - // reading resurfacing after it stopped being current. The entry that is active then will - // compute its own crossing when it is built. - if (crossing > 0 && (until <= 0 || crossing < until)) { - ComplicationData after = build(type, active, crossing + 1); - if (after != null) { - entries.add(new TimelineEntry( - new TimeInterval(Instant.ofEpochMilli(crossing), - until > crossing ? Instant.ofEpochMilli(until) : Instant.MAX), - after)); - } - } + addCrossings(type, active, entries); if (active.isReloadAtEnd()) { CN1WidgetProvider.requestAppRefresh(this, getKindId()); } @@ -376,38 +360,61 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi // characters the slot already shows -- losing exactly the text the description exists // to supply. return shortText(primary, texts.get(0), - texts.size() > 1 ? shorten(texts.get(1)) : null, tap); + texts.size() > 1 ? texts.get(1) : null, tap); } return null; } /** - * When this reading's primary text stops counting down and starts counting up, or 0. + * 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.

* - *

Only a {@code relative} node crosses: a timer counts one way by definition, and a clock - * or a date does not move at all. Only a target still in the FUTURE matters -- one already - * past is counting up from the start and never changes side again.

+ *

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.

* - * @param reading the entry being rendered - * @return the crossing moment in epoch millis, or 0 when nothing crosses + *

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 */ - private long relativeCrossing(CN1WatchSurface.Reading reading) { + private void addCrossings(ComplicationType type, CN1WatchSurface.Reading reading, + List entries) { if (reading == null) { - return 0; + return; } + long windowEnd = reading.getNextFlipDate(); + java.util.TreeSet crossings = new java.util.TreeSet(); List nodes = CN1WatchSurface.flatten(reading.getLayout()); - // BOTH nodes that can be handed over as ticking text, not only the first: a rectangular - // layout routinely puts the label first and the moving value beneath it, and the body - // ticks too. The earliest crossing wins, since that is when the timeline first differs. - long first = relativeCrossingOf(firstTextNode(nodes), reading); - long second = relativeCrossingOf(secondTextNode(nodes), reading); - if (first <= 0) { - return second; - } - if (second <= 0) { - return first; - } - return Math.min(first, second); + for (JSONObject node : new JSONObject[] {firstTextNode(nodes), secondTextNode(nodes)}) { + long at = relativeCrossingOf(node, reading); + if (at > 0 && (windowEnd <= 0 || at < windowEnd)) { + crossings.add(Long.valueOf(at)); + } + } + java.util.List ordered = new ArrayList(crossings); + for (int i = 0; i < ordered.size(); i++) { + long at = ordered.get(i).longValue(); + ComplicationData after = build(type, reading, at + 1); + if (after == null) { + continue; + } + // 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. @@ -513,16 +520,21 @@ private ComplicationText tickingText(JSONObject node, JSONObject state, long asO private ShortTextComplicationData shortText(ComplicationText ticking, String text, String title, PendingIntent tap) { - // The untruncated string becomes the content description, so a screen reader still hears - // what the layout said even where the slot shows seven characters. + // The untruncated strings become the content description, so a screen reader still hears + // what the layout said even where the slot shows seven characters. BOTH of them: the + // title is displayed too, and describing only the text announced half of what is on the + // face. The title arrives whole now and is shortened here, where its visual form is made + // -- it used to be shortened by the caller, which left nothing full to describe with. // // A ticking value is handed over whole: shortening it would mean rendering it here, which // is the freezing this exists to avoid. The face sizes what it draws. + boolean titled = title != null && title.length() > 0; ShortTextComplicationData.Builder builder = new ShortTextComplicationData.Builder( - ticking != null ? ticking : plain(shorten(text)), plain(text)); - if (title != null && title.length() > 0) { - builder.setTitle(plain(title)); + ticking != null ? ticking : plain(shorten(text)), + plain(titled ? text + ", " + title : text)); + if (titled) { + builder.setTitle(plain(shorten(title))); } builder.setTapAction(tap); return builder.build(); From 40dc02b984d4fb2d3da818f2c10e935ebabebde8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:27:54 +0300 Subject: [PATCH 78/96] Refuse an explicit null families key instead of defaulting it to the phone Last round I called "families": null a legible way to say the kind declares nothing. It is not, because there is no empty answer that means that: an EMPTY declaration deliberately means "take the home-screen default" -- that is what a kind with no families key gets, and what hasPhoneFamily documents. Returning empty therefore produced the three default iOS sizes and an Android provider, which is the opposite of what a null plainly intends, and my comment said one thing while the code did another. So a null is refused like any other value that cannot be read, with the kind id in the message. A kind that should offer nothing is a kind that should not be declared. Both meanings are pinned now: the null is refused, and the empty list still takes the default -- the second is what makes the first the right answer, so it is asserted rather than assumed. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/util/SurfaceKindFamilies.java | 14 ++++++---- .../util/SurfaceKindFamiliesTest.java | 28 +++++++++++++++---- 2 files changed, 31 insertions(+), 11 deletions(-) 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 index 6fab0a73ca5..76bc9a38f89 100644 --- 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 @@ -73,11 +73,15 @@ public static List read(Map kindJson) { // 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. - if (portable == null) { - // Explicitly nothing. Not an error -- it is a legible way to say the kind - // declares no families -- but it must not fall through to iosFamilies. - return Collections.emptyList(); - } + // 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 \"" 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 index e5fda666ae1..37e7953541a 100644 --- 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 @@ -25,6 +25,7 @@ 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; @@ -209,17 +210,32 @@ void aMalformedLegacyValueStillDegradesQuietly() { assertTrue(SurfaceKindFamilies.read(kind).isEmpty()); } - /// An explicit null is PRESENT. A JSON author writes "families": null to mean the kind - /// declares none, and reading iosFamilies instead resurrects exactly what they were removing - /// -- the same failure as a malformed value, reached through a different door. + /// 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 anExplicitNullFamiliesKeyStillWins() { + void anExplicitNullFamiliesKeyIsRefused() { Map kind = new LinkedHashMap(); kind.put("id", "status"); kind.put("families", null); kind.put("iosFamilies", Arrays.asList("small")); - assertTrue(SurfaceKindFamilies.read(kind).isEmpty(), - "the legacy list must not come back through a null portable key"); + 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"); } } From a04d6cb96a7b57406d9727a1704ca76102ad5266 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:33:36 +0300 Subject: [PATCH 79/96] Pair each text with its own node, bound crossings below, sweep after the grace Three refinements to the last few rounds. texts() drops a node that resolves to nothing -- a missing ${placeholder} is the ordinary way -- while the node lookup did not, so the two lists were indexed differently: a timer displayed after an empty label was handed over as the LABEL's static text and frozen. That is the failure the ticking work exists to prevent, reached by an off-by-one. Both lists are built by the same rule now, one node at a time through the same call texts() makes, so they cannot disagree about what resolves to nothing. A crossing also needed a lower bound. A future entry beginning in an hour can name a target thirty minutes away, and an entry starting at that target carried the future reading's content from before it was current -- overlapping what is on screen and showing tomorrow's state today. A crossing before the reading begins has already happened by the time it takes over, and the entry is built as of its own start, so it is already on the right side. And the stale-image sweep could never collect the blob that triggered it: writeAtomically gives it the current time, so it is inside the grace by definition. When that blob belongs to a superseded publication -- the last transfer of a batch the watch was too late for -- nothing else looks again. One delayed pass per kind now runs after the grace, with a pending set so a burst of arrivals does not queue one apiece. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceMirror.java | 48 +++++++++++++ .../wear/CN1ComplicationDataSource.java | 69 ++++++++++++------- .../android/os/Handler.javas | 10 +++ 3 files changed, 102 insertions(+), 25 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/os/Handler.javas diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index a94cec9b0f1..aa02e829b2e 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -336,6 +336,48 @@ public static boolean receive(Context ctx, String path, byte[] payload) { * acknowledgement is durable -- a false answer gets the transfer redelivered, a * wrongly true one loses the artwork for good */ + /// Kinds with a delayed sweep already pending, so a burst of arriving images schedules one + /// pass rather than one per image. + private static final java.util.HashSet SWEEPS_PENDING = new java.util.HashSet(); + + /** + * Runs the stale-image sweep once more after the grace period has passed. + * + *

The synchronous sweep cannot collect the blob that triggered it: it was just written, so + * it is inside the grace by definition. When that blob belongs to a superseded publication -- + * the last transfer of a batch the watch was too late for -- nothing else will look again, + * and it stays. One delayed pass per kind is enough, and the set keeps a burst of arrivals + * from queuing one apiece.

+ * + * @param ctx any context + * @param kindId the kind whose directory to sweep + */ + private static void scheduleStaleImageSweep(final Context ctx, final String kindId) { + synchronized (SWEEPS_PENDING) { + if (!SWEEPS_PENDING.add(kindId)) { + return; + } + } + final Context app = ctx.getApplicationContext(); + new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(new Runnable() { + public void run() { + synchronized (SWEEPS_PENDING) { + SWEEPS_PENDING.remove(kindId); + } + try { + String json = CN1SurfaceStore.readWidgetTimeline(app, kindId); + if (json != null && json.length() > 0) { + CN1SurfaceStore.deleteUnreferencedImages( + CN1SurfaceStore.kindDir(app, kindId), json, + STALE_IMAGE_GRACE_MILLIS); + } + } catch (Throwable t) { + Log.w(TAG, "Could not collect stale mirrored images for " + kindId, t); + } + } + }, STALE_IMAGE_GRACE_MILLIS + 1000L); + } + /// 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 @@ -396,6 +438,12 @@ public static boolean receiveFile(Context ctx, String path, byte[] payload) { if (stored != null && stored.length() > 0) { CN1SurfaceStore.deleteUnreferencedImages(dir, stored, STALE_IMAGE_GRACE_MILLIS); } + // And AGAIN once the grace has passed. This sweep necessarily spares the file it has + // just written -- writeAtomically gives it the current time -- so the last transfer + // of a superseded publication is always the one left behind, and nothing else is + // promised to run: no further descriptor, no further image. A single delayed pass + // closes that, and repeated disconnects otherwise accumulate one blob each. + scheduleStaleImageSweep(ctx, kindId); return true; } catch (Throwable t) { Log.w(TAG, "Could not store a mirrored image from " + path, t); 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 index a06dcf9f1b4..57970995e58 100644 --- 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 @@ -298,7 +298,7 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi // 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. ComplicationText primary = texts.isEmpty() ? null - : textFor(firstTextNode(nodes), reading.getState(), texts.get(0), asOf); + : textFor(textNodeAt(nodes, reading.getState(), 0), reading.getState(), texts.get(0), asOf); if (ComplicationType.LONG_TEXT.equals(type)) { String title = texts.isEmpty() ? getKindId() : texts.get(0); @@ -309,7 +309,7 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi // 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. ComplicationText bodyText = texts.size() == 2 - ? textFor(secondTextNode(nodes), reading.getState(), texts.get(1), asOf) + ? textFor(textNodeAt(nodes, reading.getState(), 1), reading.getState(), texts.get(1), asOf) : plain(body); // 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 @@ -392,11 +392,20 @@ private void addCrossings(ComplicationType type, CN1WatchSurface.Reading reading return; } long windowEnd = reading.getNextFlipDate(); + // The reading's own START is the lower bound, not just its end. A future entry beginning + // in an hour can name a target thirty minutes away, and an entry starting AT that target + // would carry the future reading's content from before it is current -- overlapping the + // entry actually on screen and showing tomorrow's state today. A crossing before the + // reading begins has already happened by the time the reading takes over, and the entry + // is built as of its own start, so it is already on the right side. + long windowStart = reading.getStart(); java.util.TreeSet crossings = new java.util.TreeSet(); List nodes = CN1WatchSurface.flatten(reading.getLayout()); - for (JSONObject node : new JSONObject[] {firstTextNode(nodes), secondTextNode(nodes)}) { + JSONObject state = reading.getState(); + for (JSONObject node : new JSONObject[] {textNodeAt(nodes, state, 0), + textNodeAt(nodes, state, 1)}) { long at = relativeCrossingOf(node, reading); - if (at > 0 && (windowEnd <= 0 || at < windowEnd)) { + if (at > 0 && at > windowStart && (windowEnd <= 0 || at < windowEnd)) { crossings.add(Long.valueOf(at)); } } @@ -427,35 +436,45 @@ private long relativeCrossingOf(JSONObject node, CN1WatchSurface.Reading reading return date > System.currentTimeMillis() ? date : 0; } - /// The second text-bearing node, which is a long-text body when there is exactly one. - private static JSONObject secondTextNode(List nodes) { - boolean seenFirst = false; + /** + * 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)) { - if (seenFirst) { - return node; - } - seenFirst = true; + 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 null; + return out; } - /// The first text-bearing node, so the caller can ask what KIND of text it is. - /// - /// texts() returns resolved strings and deliberately says nothing about where they came from; - /// a native ticking value needs the node itself. - private static JSONObject firstTextNode(List nodes) { - for (JSONObject node : nodes) { - String type = node.optString("t", ""); - if ("text".equals(type) || "dyn".equals(type)) { - return node; - } - } - return null; + /// 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, 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; } +} From 3cffc41ca07118c0f1c10114252c8e5a6f239bfd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:54:13 +0300 Subject: [PATCH 80/96] Wake at the timeline's end, split the base entry, sweep on read Four, plus a gate catch. reload-at-end asked for its fetch immediately, which spends the one throttled request hours early and can republish over entries the user has not seen. Asking only at exhaustion never happens, because nothing calls the provider when the last entry takes over. So it schedules an alarm for the timeline's end -- carrying the same broadcast the immediate path sends, to BackgroundFetchHandler, which every manifest with background fetch already declares. An alarm also survives the process, which a posted Runnable does not. The base entry ran to its reading's end while the crossing entry started at the target, so the two overlapped and a host handed overlapping intervals may reject the timeline or keep selecting the countdown. The base entry stops at the first crossing now, and both come from one computation so they cannot disagree about where one ends. Crossings scanned only the first two nodes, but a long-text body joins every value from index one, so a relative node third or later never changed sides. Every displayed node contributes now. The delayed image sweep was an in-memory Runnable in a process the system stops at will, so it died with it and the stale blob outlived the fix. Reading is the durable hook -- anything that renders reads the store first -- so the sweep happens there, across any number of process deaths. The cast-semantics gate then caught a new (AlarmManager) cast under a catch(Throwable), which is exactly its purpose: a failed CHECKCAST does not throw on ParparVM. Restructured to the instanceof-branch shape the verifier recognises. What I did NOT do is make a multi-node long-text body tick. A ticking value is an object the face advances, not a string, and nothing in the shipped androidx API states a way to surround a time difference with text -- so I would be guessing at a placeholder convention and risking the wrong thing rendered rather than a stale one. The build now says so once, with the fix: put the moving value in its own second node. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceMirror.java | 67 +++++-------- .../android/surfaces/CN1WatchSurface.java | 5 + .../android/surfaces/CN1WidgetProvider.java | 64 ++++++++++++ .../wear/CN1ComplicationDataSource.java | 97 ++++++++++++++++--- .../builders/WearGlueCompilesTest.java | 14 ++- 5 files changed, 187 insertions(+), 60 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index aa02e829b2e..7cdc3a1b567 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -336,55 +336,42 @@ public static boolean receive(Context ctx, String path, byte[] payload) { * acknowledgement is durable -- a false answer gets the transfer redelivered, a * wrongly true one loses the artwork for good */ - /// Kinds with a delayed sweep already pending, so a burst of arriving images schedules one - /// pass rather than one per image. - private static final java.util.HashSet SWEEPS_PENDING = new java.util.HashSet(); + /// 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; /** - * Runs the stale-image sweep once more after the grace period has passed. + * Collects stale mirrored artwork for a kind, called from wherever the store is read. * - *

The synchronous sweep cannot collect the blob that triggered it: it was just written, so - * it is inside the grace by definition. When that blob belongs to a superseded publication -- - * the last transfer of a batch the watch was too late for -- nothing else will look again, - * and it stays. One delayed pass per kind is enough, and the set keeps a burst of arrivals - * from queuing one apiece.

+ *

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 */ - private static void scheduleStaleImageSweep(final Context ctx, final String kindId) { - synchronized (SWEEPS_PENDING) { - if (!SWEEPS_PENDING.add(kindId)) { + 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); } - final Context app = ctx.getApplicationContext(); - new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(new Runnable() { - public void run() { - synchronized (SWEEPS_PENDING) { - SWEEPS_PENDING.remove(kindId); - } - try { - String json = CN1SurfaceStore.readWidgetTimeline(app, kindId); - if (json != null && json.length() > 0) { - CN1SurfaceStore.deleteUnreferencedImages( - CN1SurfaceStore.kindDir(app, kindId), json, - STALE_IMAGE_GRACE_MILLIS); - } - } catch (Throwable t) { - Log.w(TAG, "Could not collect stale mirrored images for " + kindId, t); - } - } - }, STALE_IMAGE_GRACE_MILLIS + 1000L); } - /// 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; - public static boolean receiveFile(Context ctx, String path, byte[] payload) { try { String kindId = kindOf(path); @@ -438,12 +425,6 @@ public static boolean receiveFile(Context ctx, String path, byte[] payload) { if (stored != null && stored.length() > 0) { CN1SurfaceStore.deleteUnreferencedImages(dir, stored, STALE_IMAGE_GRACE_MILLIS); } - // And AGAIN once the grace has passed. This sweep necessarily spares the file it has - // just written -- writeAtomically gives it the current time -- so the last transfer - // of a superseded publication is always the one left behind, and nothing else is - // promised to run: no further descriptor, no further image. A single delayed pass - // closes that, and repeated disconnects otherwise accumulate one blob each. - scheduleStaleImageSweep(ctx, kindId); return true; } catch (Throwable t) { Log.w(TAG, "Could not store a mirrored image from " + path, t); diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java index e238f02be84..8b08c95c8cf 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java @@ -147,6 +147,11 @@ public static Reading read(Context ctx, String kindId, String family) { 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); 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 881a81f9dad..62cd7f2e510 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java @@ -146,6 +146,70 @@ private void renderAll(Context context, AppWidgetManager mgr, int[] appWidgetIds /// 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 || 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)); + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= 23) { + flags |= FLAG_IMMUTABLE; + } + // 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 = PendingIntent.getBroadcast(context, + ("reloadAtEnd:" + kindId).hashCode(), intent, flags); + // 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) { try { String listenerClass = CN1SurfaceStore.getBackgroundFetchClass(context); 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 index 57970995e58..d63f6665962 100644 --- 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 @@ -159,9 +159,16 @@ private ComplicationDataTimeline buildTimeline(ComplicationType type) { 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()), - end > reading.getStart() ? Instant.ofEpochMilli(end) : Instant.MAX), + 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 @@ -194,7 +201,18 @@ private ComplicationDataTimeline buildTimeline(ComplicationType type) { CN1WatchSurface.Reading active = readings.get(0); addCrossings(type, active, entries); if (active.isReloadAtEnd()) { - CN1WidgetProvider.requestAppRefresh(this, getKindId()); + // 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); } @@ -309,8 +327,26 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi // 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. ComplicationText bodyText = texts.size() == 2 - ? textFor(textNodeAt(nodes, reading.getState(), 1), reading.getState(), texts.get(1), asOf) + ? textFor(textNodeAt(nodes, reading.getState(), 1), 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. @@ -386,30 +422,59 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi * @param reading the entry whose crossings are wanted * @param entries the timeline being assembled */ - private void addCrossings(ComplicationType type, CN1WatchSurface.Reading reading, - List entries) { + /// When the published timeline runs out, or 0 when it already has or never does. + /// + /// The LAST reading's flip date: the entries are ordered, and the final one's end is the + /// moment there is nothing left to show. + private static long timelineEnd(List readings) { + if (readings.isEmpty()) { + return 0; + } + return readings.get(readings.size() - 1).getNextFlipDate(); + } + + 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; + return new ArrayList(); } long windowEnd = reading.getNextFlipDate(); - // The reading's own START is the lower bound, not just its end. A future entry beginning - // in an hour can name a target thirty minutes away, and an entry starting AT that target - // would carry the future reading's content from before it is current -- overlapping the - // entry actually on screen and showing tomorrow's state today. A crossing before the - // reading begins has already happened by the time the reading takes over, and the entry - // is built as of its own start, so it is already on the right side. long windowStart = reading.getStart(); - java.util.TreeSet crossings = new java.util.TreeSet(); List nodes = CN1WatchSurface.flatten(reading.getLayout()); JSONObject state = reading.getState(); - for (JSONObject node : new JSONObject[] {textNodeAt(nodes, state, 0), - textNodeAt(nodes, state, 1)}) { + 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)); } } - java.util.List ordered = new ArrayList(crossings); + return new ArrayList(crossings); + } + + 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); 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 index 2f5e6447cbf..20b82315a37 100644 --- 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 @@ -93,7 +93,7 @@ void theInjectedWearServicesCompile(@TempDir Path tmp) throws IOException { collectJava(stubs.toFile(), sources); assertTrue(sources.size() > 20, "the stub tree must be there: " + STUBS); - // The two port classes CN1WatchSurface calls into are stubbed rather than compiled: they + // 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. @@ -108,6 +108,15 @@ void theInjectedWearServicesCompile(@TempDir Path tmp) throws IOException { + " 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" @@ -138,6 +147,9 @@ void theInjectedWearServicesCompile(@TempDir Path tmp) throws IOException { + "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" From 5b3eea872f36a1b92b47dbacee854ae7c048e164 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:03:48 +0300 Subject: [PATCH 81/96] Wake at the right moment, through a component that exists Two faults in the alarm added last round, and the second made the whole mechanism dead code. timelineEnd read the final reading's flip date, and readTimeline computes each reading's flip from the entries AFTER it -- so the last one's is always zero. The method therefore answered zero for every timeline, the scheduling branch was never taken, and the immediate request I was trying to replace happened anyway. It uses the final reading's START, which is also the right moment on its own terms: the timeline is exhausted when the last entry takes over, because there is nothing behind it. The alarm also carried a BROADCAST PendingIntent, while BackgroundFetchHandler is an IntentService declared as a -- so it named a receiver that does not exist and the alarm fired into nothing. It uses the port's own getPendingIntent helper now, which builds the service form with the same flags every other alarm-delivered start of this very handler uses. An alarm briefly allowlists the app, which is what permits the start on API 26+. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1WidgetProvider.java | 15 +++++++++------ .../wear/CN1ComplicationDataSource.java | 17 ++++++++++++----- 2 files changed, 21 insertions(+), 11 deletions(-) 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 62cd7f2e510..7047f079bd4 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java @@ -190,14 +190,17 @@ private static void scheduleFetchAlarm(Context context, AlarmManager am, String Intent intent = new Intent(context, com.codename1.impl.android.BackgroundFetchHandler.class); intent.setData(android.net.Uri.parse("http://codenameone.com/a?" + listenerClass)); - int flags = PendingIntent.FLAG_UPDATE_CURRENT; - if (Build.VERSION.SDK_INT >= 23) { - flags |= FLAG_IMMUTABLE; - } + // 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 = PendingIntent.getBroadcast(context, - ("reloadAtEnd:" + kindId).hashCode(), intent, flags); + 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) { 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 index d63f6665962..7aebf0bb03c 100644 --- 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 @@ -422,15 +422,22 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi * @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 or never does. + /// When the published timeline runs out, or 0 when it already has. /// - /// The LAST reading's flip date: the entries are ordered, and the final one's end is the - /// moment there is nothing left to show. + /// 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.isEmpty()) { + if (readings.size() < 2) { + // One reading is the last one, and it is already current. return 0; } - return readings.get(readings.size() - 1).getNextFlipDate(); + return readings.get(readings.size() - 1).getStart(); } private long firstCrossingOf(CN1WatchSurface.Reading reading) { From 4198629129fec343a3fb5c64c9da7d0248c439e9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:07:55 +0300 Subject: [PATCH 82/96] Let an interval gauge advance, on the Tile and in the complication A prog node carrying start and end derives its fraction from the clock, and both readers snapshot it -- so a gauge that fills over an hour showed one fraction for the whole reading, while the same node on iOS advances. The Tile asked freshnessFor whether the layout had dynamic TEXT, which a filling bar is not, so a Tile with a gauge and no countdown asked for no refresh at all and stood still. The question is now whether anything moves with the clock, which interval progress does. The complication has no update period and crossings do not help -- they exist for text changing sides, and a gauge has no side to change -- so the timeline is the only mechanism, and the visible part of the interval is stepped. Bounded twice over: at most twelve entries per node and none closer than a minute, so a five-minute interval yields four and a week-long one still moves. Checked against those shapes, plus an interval already over (no entries) and one clipped by a flip. Co-Authored-By: Claude Opus 5 (1M context) --- .../wear/CN1ComplicationDataSource.java | 51 +++++++++++++++++++ .../surfaces/wear/CN1SurfaceTileService.java | 26 +++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) 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 index 7aebf0bb03c..22bf5edb572 100644 --- 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 @@ -472,9 +472,60 @@ private java.util.List crossingsOf(CN1WatchSurface.Reading reading) { 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; + } + long from = Math.max(node.optLong("start"), windowStart); + 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)); + } + } + } + private void addCrossings(ComplicationType type, CN1WatchSurface.Reading reading, List entries) { if (reading == null) { 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 index 2915d107cee..e048a948b9e 100644 --- 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 @@ -169,7 +169,7 @@ private TileBuilders.Tile buildTile() { } else { root = render(reading.getLayout(), reading.getState(), 0, false); freshness = freshnessFor(reading.getNextFlipDate(), - hasDynamicText(reading.getLayout())); + hasMovingContent(reading.getLayout())); version = resourcesVersion(reading); // Kept for the resources request that follows, which asks about THIS version. remember(version, reading); @@ -207,6 +207,30 @@ private TileBuilders.Tile buildTile() { * 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) { + if (hasDynamicText(layout)) { + return true; + } + for (JSONObject node : CN1WatchSurface.flatten(layout)) { + if ("prog".equals(node.optString("t", "")) + && node.has("start") && node.has("end")) { + return true; + } + } + return false; + } + private static long freshnessFor(long nextFlipDate, boolean hasDynamicText) { long delta = nextFlipDate > 0 ? nextFlipDate - System.currentTimeMillis() : Long.MAX_VALUE; if (nextFlipDate <= 0 && !hasDynamicText) { From 53704d39c9d31b80592e3d649f570e5c645ff6a0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:13:27 +0300 Subject: [PATCH 83/96] Keep the watch StubUtil, and give it the method the push glue calls Two breaks in last round's push retargeting, both of which would have failed the wear build outright. The exclusion removed BOTH copies. A Gradle exclude applies to the whole source set, and the phone root and the module's own root hold the same relative path -- so '**/StubUtil.java' took out the watch-specific replacement along with the phone's, leaving the shared messaging service referencing a class that was no longer compiled at all. It is scoped by absolute path now, so only the file under app/src/main/java is dropped. And the replacement was missing getMain(). Every bundled CN1FirebaseMessagingService template calls it, so a companion build with FCM failed on a class the developer never wrote. All three of the phone copy's methods are mirrored now, with the same visibility and return types -- getMain package-private and returning Object, as it is there. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/builders/AndroidGradleBuilder.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 3063367060b..bdd2b75e9ed 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 @@ -8115,6 +8115,12 @@ private void writeWatchStubUtil(BuildRequest request, File wearSrc, String stubN + " }\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"); @@ -8158,7 +8164,15 @@ static String deriveWearGradle(String appGradle, int intVersion, int wearVersion // 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. - + " java.exclude '**/com/codename1/impl/android/StubUtil.java'\n" + // 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 From 7501069273d1030718c40a8873dd22405c913a0c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:29:05 +0300 Subject: [PATCH 84/96] Check a remote image against its own name, and stop refreshing what stands still An image name is a CLAIM about the bytes beside it, and a descriptor from a server or the watch mirror came from outside this process. iOS skips writing a blob whose file already exists, on the strength of that claim -- so corrupted bytes landing first cannot be repaired by any later legitimate publish, and the surface shows wrong artwork for good. Hash-shaped names are now checked against their content, reusing the serializer's own fnv1a rather than a second copy. A name that was never a hash is passed through rather than refused for failing a test that does not apply to it. Tile freshness claimed movement from things that had stopped moving. A finished interval is clamped at its completed value, a time or date style formats the node's own fixed timestamp, and an expired countdown sits at zero -- yet each of them asked for a rebuild every minute, for ever, redrawing an identical Tile at the cost of the refresh budget and the battery. The question is now whether the content is still moving, not whether it is the kind of content that can. And an interval sample whose entry cannot render the requested type left its stretch uncovered: the base entry had already been ended at the first sample, so the timeline fell back to its default -- the older reading, resurfacing after it stopped being current. It substitutes no-data, exactly as the main loop does. hasDynamicText went with the freshness change; nothing called it any more. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/surfaces/SurfaceSerializer.java | 5 +- .../src/com/codename1/surfaces/Surfaces.java | 28 ++++++++++ .../wear/CN1ComplicationDataSource.java | 53 +++++++++++++++---- .../surfaces/wear/CN1SurfaceTileService.java | 45 ++++++++++------ 4 files changed, 104 insertions(+), 27 deletions(-) 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 05cf0a61b94..b4f03a391a8 100644 --- a/CodenameOne/src/com/codename1/surfaces/Surfaces.java +++ b/CodenameOne/src/com/codename1/surfaces/Surfaces.java @@ -264,6 +264,23 @@ public static void publishRemote(String kindId, String timelineJson, /// 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. + /// Whether a name has the shape SurfaceSerializer gives a content hash: sixteen lowercase + /// hex digits. Only those are verified, so a name 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() != 16) { + return false; + } + for (int i = 0; i < 16; 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(); @@ -278,6 +295,17 @@ private static Map safeImageNames(Map images) { + name); continue; } + if (looksLikeContentHash(name) && e.getValue() != null + && !name.equals(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 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 index 22bf5edb572..1c3ec365174 100644 --- 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 @@ -315,8 +315,12 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi // 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. - ComplicationText primary = texts.isEmpty() ? null - : textFor(textNodeAt(nodes, reading.getState(), 0), reading.getState(), texts.get(0), asOf); + 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); @@ -326,9 +330,15 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi // 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. - ComplicationText bodyText = texts.size() == 2 - ? textFor(textNodeAt(nodes, reading.getState(), 1), reading.getState(), - texts.get(1), asOf) + // 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 @@ -351,9 +361,18 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi // 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. + ComplicationText spokenText = plain(spoken); + if (body.length() > 0 && bodyTicks) { + spokenText = bodyText; + } else if (body.length() == 0 && titleTicks) { + spokenText = titleText; + } LongTextComplicationData.Builder builder = new LongTextComplicationData.Builder( - body.length() == 0 ? titleText : bodyText, plain(spoken)) + body.length() == 0 ? titleText : bodyText, spokenText) .setTitle(body.length() == 0 ? null : titleText) .setTapAction(tap); return builder.build(); @@ -395,7 +414,7 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi // 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. - return shortText(primary, texts.get(0), + return shortText(titleTicks ? primary : null, texts.get(0), texts.size() > 1 ? texts.get(1) : null, tap); } return null; @@ -537,7 +556,11 @@ private void addCrossings(ComplicationType type, CN1WatchSurface.Reading reading long at = ordered.get(i).longValue(); ComplicationData after = build(type, reading, at + 1); if (after == null) { - continue; + // 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. @@ -671,10 +694,20 @@ private ShortTextComplicationData shortText(ComplicationText ticking, String tex // A ticking value is handed over whole: shortening it would mean rendering it here, which // is the freezing this exists to avoid. The face sizes what it draws. boolean titled = title != null && title.length() > 0; + // The description TICKS too when the value does. It was a plain string resolved at + // request time, so with no update period a screen reader went on announcing the moment + // the provider was called long after the face had moved on -- reading out a time that is + // simply wrong. The ticking text is the same object the face advances, so it stays right. + // + // The title is not folded into it in that case, 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, and the title is static text + // the face is already showing beside it. + ComplicationText described = ticking != null ? ticking + : plain(titled ? text + ", " + title : text); ShortTextComplicationData.Builder builder = new ShortTextComplicationData.Builder( - ticking != null ? ticking : plain(shorten(text)), - plain(titled ? text + ", " + title : text)); + ticking != null ? ticking : plain(shorten(text)), described); if (titled) { builder.setTitle(plain(shorten(title))); } 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 index e048a948b9e..5dbdec1bd9b 100644 --- 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 @@ -169,7 +169,7 @@ private TileBuilders.Tile buildTile() { } else { root = render(reading.getLayout(), reading.getState(), 0, false); freshness = freshnessFor(reading.getNextFlipDate(), - hasMovingContent(reading.getLayout())); + hasMovingContent(reading.getLayout(), reading.getState())); version = resourcesVersion(reading); // Kept for the resources request that follows, which asks about THIS version. remember(version, reading); @@ -218,15 +218,36 @@ private TileBuilders.Tile buildTile() { * @param layout the resolved layout root * @return true when the Tile should be rebuilt periodically */ - private static boolean hasMovingContent(JSONObject layout) { - if (hasDynamicText(layout)) { - return true; - } + private static boolean hasMovingContent(JSONObject layout, JSONObject state) { + long now = System.currentTimeMillis(); for (JSONObject node : CN1WatchSurface.flatten(layout)) { - if ("prog".equals(node.optString("t", "")) - && node.has("start") && node.has("end")) { - return true; + 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. + if (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; + } + if ("timerDown".equals(style) + && CN1WatchSurface.dynamicDate(node, state) <= now) { + // An expired countdown is clamped at zero and equally still. + continue; } + return true; } return false; } @@ -253,14 +274,6 @@ private static long freshnessFor(long nextFlipDate, boolean hasDynamicText) { } /// Whether the active layout shows anything that changes on its own. - private static boolean hasDynamicText(JSONObject root) { - for (JSONObject node : CN1WatchSurface.flatten(root)) { - if ("dyn".equals(node.optString("t", ""))) { - return true; - } - } - return false; - } /** * The version the Tile advertises for its resource set. From 3eae16e2c397442f00fa2a0a60d68f65b0b55796 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:38:57 +0300 Subject: [PATCH 85/96] Let the gauge reach full, and tick every text that is displayed Four refinements, all of the same shape: a value the face could advance was being handed over frozen, or not handed over at all. The interval sampler stopped short of the interval's own end, 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. The endpoint is emitted when the interval finishes inside the reading; when a flip covers it instead, nothing is added, because the flip already ends that entry. Checked against a whole interval, one clipped by a flip, and one already over. A SHORT_TEXT title is displayed and could tick, and did not: a countdown put in the second node froze exactly as the primary one used to. A RANGED_VALUE description and a LONG_TEXT description whose title is the only moving part were both still request-time strings, so a screen reader announced a time long after the face had moved on. Whichever part actually moves now describes the whole -- the body when both move, being the value rather than the label. Co-Authored-By: Claude Opus 5 (1M context) --- .../wear/CN1ComplicationDataSource.java | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) 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 index 1c3ec365174..0fe71f08c2f 100644 --- 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 @@ -265,7 +265,7 @@ private ComplicationData placeholder(ComplicationType type) { // 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); + return shortText(null, label, null, null, null); } /** @@ -364,10 +364,14 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi // 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 (body.length() == 0 && titleTicks) { + } else if (titleTicks) { spokenText = titleText; } LongTextComplicationData.Builder builder = @@ -385,9 +389,13 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi // 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, - plain(texts.isEmpty() ? getKindId() : texts.get(0))); + new RangedValueComplicationData.Builder(value, 0f, 1f, rangedDescription); if (!texts.isEmpty()) { builder.setText(primary != null ? primary : plain(shorten(texts.get(0)))); } @@ -414,8 +422,14 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi // 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, tap); + texts.size() > 1 ? texts.get(1) : null, tickingTitle, tap); } return null; } @@ -542,6 +556,13 @@ private static void addIntervalSamples(List nodes, long windowStart, 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)); + } } } @@ -684,7 +705,7 @@ private ComplicationText tickingText(JSONObject node, JSONObject state, long asO } private ShortTextComplicationData shortText(ComplicationText ticking, String text, - String title, PendingIntent tap) { + String title, ComplicationText tickingTitle, PendingIntent tap) { // The untruncated strings become the content description, so a screen reader still hears // what the layout said even where the slot shows seven characters. BOTH of them: the // title is displayed too, and describing only the text announced half of what is on the @@ -709,7 +730,9 @@ private ShortTextComplicationData shortText(ComplicationText ticking, String tex new ShortTextComplicationData.Builder( ticking != null ? ticking : plain(shorten(text)), described); if (titled) { - builder.setTitle(plain(shorten(title))); + // 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(); From 4fe3a9923a83acc7843dc62223914765cd3075dc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:43:15 +0300 Subject: [PATCH 86/96] Sweep stale artwork on every read path, not just one collectStaleImages hung off read(), but a complication renders through readTimeline and a Tile's resource rebuild through readAllEntries -- so a kind declaring only watch families never took the one path that sweeps. Those are exactly the watches that need it: the mirror is their only source of artwork, so a blob spared by the grace period was never reconsidered and repeated delayed deliveries grew storage without bound. Reading is the durable hook because it always happens again, and that only holds if every reader does it. All three now do. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/android/surfaces/CN1WatchSurface.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java index 8b08c95c8cf..d1821bda95b 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurface.java @@ -189,6 +189,11 @@ public static Reading read(Context ctx, String kindId, String family) { */ 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; @@ -248,6 +253,11 @@ public static List readTimeline(Context ctx, String kindId, String fami */ 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; From 5693f434bf1761516838b20acc5608ad36187619 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:50:18 +0300 Subject: [PATCH 87/96] Sample from now onward, and wait for an interval to start The active reading's own start is in the past, so sampling from there emitted timeline 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 showed a gauge fourteen hours stale the instant it appeared. Sampling starts no earlier than now; a future reading is unaffected, because now is before its start and the clamp does nothing there. Checked against both. And a Tile treated an interval that had not BEGUN as moving, because the check asked only whether it had ended. A reading published hours ahead therefore asked for a rebuild every minute throughout, redrawing a bar clamped at zero. Running now means started as well as unfinished. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/wear/CN1ComplicationDataSource.java | 9 ++++++++- .../builders/surfaces/wear/CN1SurfaceTileService.java | 6 +++++- 2 files changed, 13 insertions(+), 2 deletions(-) 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 index 0fe71f08c2f..5b653dd76c8 100644 --- 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 @@ -543,7 +543,14 @@ private static void addIntervalSamples(List nodes, long windowStart, || !node.has("start") || !node.has("end")) { continue; } - long from = Math.max(node.optLong("start"), windowStart); + // 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); 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 index 5dbdec1bd9b..1f042715756 100644 --- 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 @@ -227,7 +227,11 @@ private static boolean hasMovingContent(JSONObject layout, JSONObject state) { // 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. - if (now < node.optLong("end")) { + // 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; From 34eb5651de3d4812e5e947bf5b0f792446617760 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:56:07 +0300 Subject: [PATCH 88/96] Wake the Tile when its interval starts, not throughout the wait Refusing periodic refresh before an interval begins was right -- the bar is clamped at zero until then, and asking every minute redrew an identical Tile for hours. On its own, though, it left nothing to wake the Tile AT the start, so a reading with no flip date would have sat at zero indefinitely: the fix for the waste created a freeze. The freshness is now the earlier of the flip date and the moment something starts moving. One refresh at that moment is enough -- the rebuild then sees the interval running and asks for the periodic rate itself. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/wear/CN1SurfaceTileService.java | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) 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 index 1f042715756..510cf2e233a 100644 --- 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 @@ -168,7 +168,15 @@ private TileBuilders.Tile buildTile() { root = text("No data yet"); } else { root = render(reading.getLayout(), reading.getState(), 0, false); - freshness = freshnessFor(reading.getNextFlipDate(), + // 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(), + nextIntervalStart(reading.getLayout())); + freshness = freshnessFor(wakeAt, hasMovingContent(reading.getLayout(), reading.getState())); version = resourcesVersion(reading); // Kept for the resources request that follows, which asks about THIS version. @@ -256,6 +264,43 @@ private static boolean hasMovingContent(JSONObject layout, JSONObject state) { 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 does not move, so it + * earns no periodic refresh -- but something has to bring the Tile back when it does 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 nextIntervalStart(JSONObject layout) { + long now = System.currentTimeMillis(); + long earliest = 0; + for (JSONObject node : CN1WatchSurface.flatten(layout)) { + if (!"prog".equals(node.optString("t", "")) + || !node.has("start") || !node.has("end")) { + continue; + } + long start = node.optLong("start"); + if (start > now && now < node.optLong("end")) { + earliest = earlierOf(earliest, start); + } + } + return earliest; + } + private static long freshnessFor(long nextFlipDate, boolean hasDynamicText) { long delta = nextFlipDate > 0 ? nextFlipDate - System.currentTimeMillis() : Long.MAX_VALUE; if (nextFlipDate <= 0 && !hasDynamicText) { From 8db952d2bce2b387e8ab1b5266b01de48c09004a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:05:37 +0300 Subject: [PATCH 89/96] Let a ticking title describe the complication too A static value beside a ticking title left the description a snapshot: the visible title advanced while a screen reader went on announcing its request-time value, and with no update period nothing corrected it. The rule the long-text branch already follows now holds here as well -- whichever part moves describes the whole, the value first and then the title. The comment block had accumulated three overlapping paragraphs as this branch was corrected round by round; it says the whole rule once now. Co-Authored-By: Claude Opus 5 (1M context) --- .../wear/CN1ComplicationDataSource.java | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) 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 index 5b653dd76c8..52dcc293273 100644 --- 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 @@ -713,26 +713,31 @@ private ComplicationText tickingText(JSONObject node, JSONObject state, long asO private ShortTextComplicationData shortText(ComplicationText ticking, String text, String title, ComplicationText tickingTitle, PendingIntent tap) { - // The untruncated strings become the content description, so a screen reader still hears - // what the layout said even where the slot shows seven characters. BOTH of them: the - // title is displayed too, and describing only the text announced half of what is on the - // face. The title arrives whole now and is shortened here, where its visual form is made - // -- it used to be shortened by the caller, which left nothing full to describe with. - // - // A ticking value is handed over whole: shortening it would mean rendering it here, which - // is the freezing this exists to avoid. The face sizes what it draws. boolean titled = title != null && title.length() > 0; - // The description TICKS too when the value does. It was a plain string resolved at - // request time, so with no update period a screen reader went on announcing the moment - // the provider was called long after the face had moved on -- reading out a time that is - // simply wrong. The ticking text is the same object the face advances, so it stays right. + // 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. // - // The title is not folded into it in that case, 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, and the title is static text - // the face is already showing beside it. - ComplicationText described = ticking != null ? ticking - : plain(titled ? text + ", " + title : text); + // 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); From d82b4418a798b7b26de97f59f638f0d031961819 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:14:47 +0300 Subject: [PATCH 90/96] Let a mirrored watch ask the phone for fresh content reload-at-end did nothing on a companion watch. The refresh request looks up a background-fetch listener recorded by publishWidgetTimeline -- and a watch never runs that method: its descriptors arrive through CN1SurfaceMirror.receive instead. So the preference was unset, the request returned immediately, and a mirrored complication sat on its final entry until the phone happened to publish. Asking the watch was the wrong device anyway. The content is produced on the phone, so the request now goes back up the link the descriptor came down: a reserved /cn1surfacereload path, routed like the descriptors themselves before anything app-visible, answered on the phone by the same throttled request a widget makes. Absent everywhere it does not apply -- a build with no wearable link has no bridge to call, and an older injected bridge has no such method, both of which leave the previous behaviour. The scheduled form asks immediately rather than at the timeline's end, because the alarm needs a local component to deliver to and a mirrored watch has none. That is stated where it is done rather than left to look like an oversight. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1SurfaceMirror.java | 27 ++++++++++++ .../surfaces/CN1WatchSurfaceNotifier.java | 29 ++++++++++++ .../android/surfaces/CN1WidgetProvider.java | 18 +++++++- .../builders/wearable/CN1WearableBridge.java | 44 +++++++++++++++++++ .../wearable/CN1WearableListenerService.java | 16 ++++++- .../builders/WearableGlueCompilesTest.java | 11 ++++- 6 files changed, 142 insertions(+), 3 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index 7cdc3a1b567..e1e1d71b743 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -496,6 +496,33 @@ public static boolean remove(Context ctx, String path) { } } + /** + * 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) { + CN1WidgetProvider.requestAppRefresh(ctx, kindId); + } + } 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); diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java index c73c9f246fd..baa5bd2d8b2 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WatchSurfaceNotifier.java @@ -66,6 +66,35 @@ public static void requestUpdate(Context ctx, String kindId) { 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); 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 7047f079bd4..061d57fc4aa 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java @@ -166,7 +166,16 @@ private void renderAll(Context context, AppWidgetManager mgr, int[] appWidgetIds static void scheduleAppRefresh(Context context, String kindId, long whenMillis) { try { String listenerClass = CN1SurfaceStore.getBackgroundFetchClass(context); - if (listenerClass == null || whenMillis <= System.currentTimeMillis()) { + 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 @@ -217,6 +226,13 @@ static void requestAppRefresh(Context context, String kindId) { try { String listenerClass = CN1SurfaceStore.getBackgroundFetchClass(context); if (listenerClass == null) { + // 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/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 8789c1baffc..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 @@ -3530,6 +3530,50 @@ private static boolean claimRetryChain(Uri uri) { * @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); 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 966e65abe72..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 @@ -175,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 @@ -524,7 +536,9 @@ private boolean surfaceMirror(String method, String path, byte[] payload) { return false; } try { - if (payload == null && "remove".equals(method)) { + // 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); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java index fe4fa331c17..a9f669120e1 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java @@ -98,7 +98,7 @@ void theInjectedWearableGlueCompiles(@TempDir Path tmp) throws IOException { collectJava(stubs.toFile(), sources); assertTrue(sources.size() > 20, "the stub tree must be there: " + STUBS); - // The mirror's three collaborators are shimmed rather than compiled: they reach into the + // 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"); @@ -131,6 +131,15 @@ void theInjectedWearableGlueCompiles(@TempDir Path tmp) throws IOException { + "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" + + "}\n").getBytes("UTF-8")); collectJava(tmp.resolve("shims").toFile(), sources); Path out = tmp.resolve("classes"); From 9e2c87247dd6b6bee3b2c02bcd37ac2decca15a0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:22:31 +0300 Subject: [PATCH 91/96] Stop the reload request bouncing, and make the hash check actually fire The peer fallback I added last round loops. A watch with no listener asks the phone; the phone answers by calling the same method, finds no listener either, and asks the watch back -- neither ever acquires one, so the two wake each other until they disconnect. An answer to a peer's request is now forbidden to ask a peer: it asked because it has nothing, and this device having nothing either is the end of it, not the start of a round trip. And the integrity check never ran. Serializer names are "img" plus sixteen hex digits, while the predicate demanded exactly sixteen characters -- so it matched nothing the framework produces, and had it matched it would have compared a prefixed name against an unprefixed hash and rejected every legitimate blob. Both halves now use the prefix, and a name that was never a hash is still passed through rather than failing a test that does not apply to it. Checked on a real generated name: recognised, matching bytes accepted, corrupted bytes rejected, an app-registered name left alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/com/codename1/surfaces/Surfaces.java | 18 ++++++++++----- .../android/surfaces/CN1SurfaceMirror.java | 5 ++++- .../android/surfaces/CN1WidgetProvider.java | 22 +++++++++++++++++++ .../builders/WearableGlueCompilesTest.java | 3 +++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/surfaces/Surfaces.java b/CodenameOne/src/com/codename1/surfaces/Surfaces.java index b4f03a391a8..e121a2acbe3 100644 --- a/CodenameOne/src/com/codename1/surfaces/Surfaces.java +++ b/CodenameOne/src/com/codename1/surfaces/Surfaces.java @@ -264,15 +264,23 @@ public static void publishRemote(String kindId, String timelineJson, /// 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. - /// Whether a name has the shape SurfaceSerializer gives a content hash: sixteen lowercase - /// hex digits. Only those are verified, so a name that was never a hash -- a registered image + /// 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() != 16) { + if (name.length() != CONTENT_HASH_PREFIX.length() + 16 + || !name.startsWith(CONTENT_HASH_PREFIX)) { return false; } - for (int i = 0; i < 16; i++) { + 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; @@ -296,7 +304,7 @@ private static Map safeImageNames(Map images) { continue; } if (looksLikeContentHash(name) && e.getValue() != null - && !name.equals(SurfaceSerializer.fnv1a(e.getValue()))) { + && !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 diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java index e1e1d71b743..6425afbe128 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceMirror.java @@ -515,7 +515,10 @@ public static boolean remove(Context ctx, String path) { public static boolean reloadRequested(Context ctx, String kindId) { try { if (kindId != null && kindId.length() > 0) { - CN1WidgetProvider.requestAppRefresh(ctx, kindId); + // 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); 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 061d57fc4aa..3c402364e5c 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java @@ -223,9 +223,31 @@ private static void scheduleFetchAlarm(Context context, AlarmManager am, String } 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 diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java index a9f669120e1..1397b429a1b 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java @@ -139,6 +139,9 @@ void theInjectedWearableGlueCompiles(@TempDir Path tmp) throws IOException { + "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); From 6408d79efb16fc02c6999bee544aee3bba0b76bc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:27:02 +0300 Subject: [PATCH 92/96] Describe a monochrome complication with its ticking value This slot shows only an icon, so the content description IS the value to a screen reader -- and it was a string resolved at request time, announced long after the face had advanced with no update period to correct it. It uses the ticking text when there is one, which is now the rule all four types follow. The two remaining plain descriptions are the pre-publish placeholder, where the label is the kind's name and nothing moves. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/surfaces/wear/CN1ComplicationDataSource.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index 52dcc293273..d541cb6ebbe 100644 --- 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 @@ -407,10 +407,15 @@ private ComplicationData build(ComplicationType type, CN1WatchSurface.Reading gi 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(), - plain(texts.isEmpty() ? getKindId() : texts.get(0))) + titleTicks && primary != null ? primary + : plain(texts.isEmpty() ? getKindId() : texts.get(0))) .setTapAction(tap); return builder.build(); } From f1987c37b5e4001156d5af0548d72e85c3399891 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:36:18 +0300 Subject: [PATCH 93/96] Park the waiting complication queue, and let a dormant timer sleep Only the head of the complication queue is ever handed to WCSession -- that is what keeps a prioritised transfer from displacing the one before it -- so everything behind it lived in memory alone. A suspension or termination during a background transfer took those kinds with it, and no system-owned transfer existed for them either, so their complications stayed stale until something else published. The queue is parked on disk now and read back on activation, which is the one thing that always happens whatever brought the process up. A kind published since the restore is newer than what was parked, so the parked copy is dropped rather than overwriting it. The same idiom the received-transfer inbox already uses, and for the same stated reason: the process does not own its own lifetime. And a count-up toward a future target reads 0:00 until it arrives, so a Tile carrying one asked for an identical rebuild every minute through the whole wait. It is dormant now and wakes at its target, exactly as a not-yet-started interval does -- the two cases are the same shape, so they share the one method that decides when to come back. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/CN1WatchConnectivity.m | 82 +++++++++++++++++++ .../surfaces/wear/CN1SurfaceTileService.java | 39 ++++++--- 2 files changed, 109 insertions(+), 12 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m index 2a04fdc9676..196762d23b2 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. @@ -966,8 +980,74 @@ - (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; } @@ -1043,6 +1123,7 @@ - (void)enqueueComplicationUserInfo:(NSDictionary *)info forKind:(NSString *)kin [_pendingComplicationOrder addObject:kind]; } [_pendingComplications setObject:info forKey:kind]; + [self persistPendingComplicationsLocked]; } [self sendNextComplicationUserInfo]; } @@ -1141,6 +1222,7 @@ - (void)finishComplicationForKind:(NSString *)kind { [_pendingComplicationOrder removeObject:kind]; [_pendingComplicationOrder addObject:kind]; } + [self persistPendingComplicationsLocked]; } [self sendNextComplicationUserInfo]; } 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 index 510cf2e233a..6a973cdd330 100644 --- 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 @@ -175,7 +175,7 @@ private TileBuilders.Tile buildTile() { // 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(), - nextIntervalStart(reading.getLayout())); + nextMovingStart(reading.getLayout(), reading.getState())); freshness = freshnessFor(wakeAt, hasMovingContent(reading.getLayout(), reading.getState())); version = resourcesVersion(reading); @@ -254,11 +254,17 @@ private static boolean hasMovingContent(JSONObject layout, JSONObject state) { // itself every minute to redraw the same string. continue; } - if ("timerDown".equals(style) - && CN1WatchSurface.dynamicDate(node, state) <= now) { + 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; @@ -278,24 +284,33 @@ private static long earlierOf(long a, long 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 does not move, so it - * earns no periodic refresh -- but something has to bring the Tile back when it does begin, - * and a reading with no flip date has nothing else that would.

+ *

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 nextIntervalStart(JSONObject layout) { + private static long nextMovingStart(JSONObject layout, JSONObject state) { long now = System.currentTimeMillis(); long earliest = 0; for (JSONObject node : CN1WatchSurface.flatten(layout)) { - if (!"prog".equals(node.optString("t", "")) - || !node.has("start") || !node.has("end")) { + 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; } - long start = node.optLong("start"); - if (start > now && now < node.optLong("end")) { - earliest = earlierOf(earliest, start); + // 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; From 44a20b3e9d1099a9d255b5560a4963cd6f5e509a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:43:20 +0300 Subject: [PATCH 94/96] Do not judge the watch before the session can answer shared activates WCSession asynchronously, so the first publish in a fresh process reaches the mirror before activation completes -- and until it does, isPaired and isWatchAppInstalled are not reliable and a transfer may be refused outright. Deciding there discarded the only copy of that payload on the strength of an answer the session was not ready to give, and nothing would have sent it again. An unactivated session now queues instead, which also parks the payload on disk, and activationDidCompleteWithState sends it once the session can actually be asked. The queue drain refuses to run before activation for the same reason: spending payloads on transfers that may be refused is the discard this queue exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/CN1WatchConnectivity.m | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m index 196762d23b2..ea39a35d954 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -1071,7 +1071,26 @@ + (void)mirrorComplicationUserInfo:(NSDictionary *)info { // surfaces and never calls the wearable API. CN1WatchConnectivity *self_ = [CN1WatchConnectivity shared]; WCSession *s = [self_ session]; - if (s == nil || !s.isPaired || !s.isWatchAppInstalled) { + 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. return; } @@ -1102,10 +1121,6 @@ + (void)mirrorComplicationUserInfo:(NSDictionary *)info { } return; } - NSString *kind = [info objectForKey:@"cn1.surfaces.kind"]; - if (kind == nil) { - kind = @""; - } [self_ enqueueComplicationUserInfo:info forKind:kind]; #endif } @@ -1147,7 +1162,10 @@ - (void)sendNextComplicationUserInfo { _complicationInFlightTransfer = nil; } WCSession *s = [self session]; - if (s == nil) { + // 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; @@ -1692,6 +1710,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 From 4e04f11c15b5a37fd0bb4769ed76712cc0f2f9c2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:57:02 +0300 Subject: [PATCH 95/96] Serialize publishes of one kind against each other A publish is a write followed by a hand-off: the platform replaces the timeline in its container and then gives the same descriptor to the watch, and the two are only meaningful as a pair. Let two publishes of one kind interleave and the later write pairs 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: both platforms read the blobs back off disk at hand-off time, so one publish's descriptor could be sent with another's artwork, 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 it. The monitor is per kind rather than global, since a publish is file I/O plus a synchronous native call and two kinds have nothing to say to each other. publishRemote takes the same one: a push landing while the app publishes races identically. Also returns the Wear module's R8 map with the build. It could not ride the source export -- that zip excludes every directory named "build", which is where R8 writes -- so a companion build's watch frames had no map at all. The new test pins the naming the extractor pairs it by. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/com/codename1/surfaces/Surfaces.java | 42 ++++++++++++- .../surfaces/AndroidSurfaceBridge.java | 4 ++ .../codename1/impl/ios/IOSSurfaceBridge.java | 4 ++ .../maven/CN1BuildResultArtifactRoleTest.java | 12 ++++ .../com/codename1/surfaces/SurfaceTest.java | 61 ++++++++++++++++++- 5 files changed, 120 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/surfaces/Surfaces.java b/CodenameOne/src/com/codename1/surfaces/Surfaces.java index e121a2acbe3..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,7 +198,40 @@ 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 @@ -247,7 +281,11 @@ public static void publishRemote(String kindId, String timelineJson, + kindId); return; } - b.publishWidgetTimeline(kindId, timelineJson, safeImageNames(images)); + // 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. 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 ee1156e6a61..ba9b8cd098a 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java @@ -104,6 +104,10 @@ public void registerWidgetKind(String kindJson) { } } + // 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) { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java index 273c75f5f91..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(); 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 index 8d2372aef3c..568eed7d941 100644 --- 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 @@ -100,6 +100,18 @@ void anAppNamedWearWithACompanionKeepsBothArtifacts() { 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() { 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 4eb4d1c7b6f..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 @@ -53,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; @@ -435,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() { From 7428badaa2f23672d66a4c1570845d4dd9b1c713 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:09:05 +0300 Subject: [PATCH 96/96] Judge the watch transfer when it is sent, not when it is published The delivery ladder ran at the publish: pairing and installed-app first, then the budgeted complication wake, falling back to a plain queued transfer when no complication is placed or the daily budget is spent. Only payloads that wanted the wake were queued, so the queue could send them blind. Two paths now reach that queue without having been judged. A publish that arrives before the session activates is queued precisely because the session cannot be asked yet, and a queue restored from disk was judged in a previous run of the app, if at all. Both were then sent down the budgeted path regardless -- spending a transfer on a watch with no complication placed, or on a budget already exhausted, where the resulting exception retires the payload. That is the discard the queue exists to prevent. The ladder moves to the send, which is the only point where every payload passes through it and where the session's answers are current. The publish keeps one cheap test, for a phone with no watch at all, so the common install does not write a queue file and delete it again -- labelled as the fast path it is, with the authoritative copy naming it. Also carries android.xmanifest's uses-sdk attributes into the Wear manifest. A tools:overrideLibrary is how a project accepts a dependency whose manifest demands a higher minSdk than the app declares, and since the wear module keeps the phone's dependency graph it merges that same library manifest -- so a project that builds today failed in the wear merge instead. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/CN1WatchConnectivity.m | 75 ++++++++++++------- .../builders/AndroidGradleBuilder.java | 31 ++++++++ .../builders/WearModuleGradleTest.java | 32 ++++++++ 3 files changed, 111 insertions(+), 27 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m index ea39a35d954..8c7be1b281d 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -1092,35 +1092,15 @@ + (void)mirrorComplicationUserInfo:(NSDictionary *)info { } 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; } - // The ladder, weakest guarantee last. - // - // 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) { - // transferUserInfo: QUEUES -- successive calls all survive -- so it needs none of the - // sequencing below. - @try { - [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); - } - return; - } + // Which transfer to spend is decided at the moment of SENDING, not here: see + // sendNextComplicationUserInfo. [self_ enqueueComplicationUserInfo:info forKind:kind]; #endif } @@ -1173,6 +1153,47 @@ - (void)sendNextComplicationUserInfo { } 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; 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 bdd2b75e9ed..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 @@ -7521,6 +7521,29 @@ private String launcherTheme() { ? "@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. * @@ -7533,6 +7556,7 @@ private String launcherTheme() { * @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(" \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, 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 index 2cac0e433dc..9f1659aa711 100644 --- 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 @@ -289,6 +289,38 @@ void librariesAreSharedFromTheAppModule() { 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("