From f5863b4619b81c2504a139b8c6a34550b078772b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:11:33 +0300 Subject: [PATCH 001/250] Watch build: one entry point, no hints, and a watch app that reaches the cloud Declaring codename1.watchMain is now the entire opt-in for a watch app on both Apple Watch and Wear OS. Nine build hints are deleted; the bundle id, deployment target, signing team and display name are derived from settings the project already has. The only other recognized setting is codename1.watchStandalone, which says the watch app ships on its own rather than inside the phone app -- the one thing that cannot be inferred. Three shipped bugs fall out of this: - A cloud build never produced a watch app. codename1.watchMain was lifted into a build argument only on the local path; the server reads only codename1.arg.* keys out of the uploaded settings file, so the daemon's WatchNativeBuilder asked for "watchMain" and got nothing. createAntProject now mirrors the secondary entry points into that namespace. - The documented companion default never embedded the watch app. watchNative.embedCompanion defaulted to false, so the "Embed Watch Content" phase was actively removed even in companion mode. Embedding is what declaring a watchMain next to a phone main means, so it is no longer opt-in. - watchMain reached only the iOS build. Wear OS was enabled by an unrelated android.wear hint, so a project had to say the same thing twice. Both platforms now read the same declaration. The five byte-identical watchMain/tvMain blocks in CN1BuildMojo collapse into one table, and WatchNativeBuilder gains the unit tests it never had (10 cases pinning enablement, distribution, the Info.plist and the generated entry point) plus 4 covering the cloud mirroring. Mirrored to the BuildDaemon (WatchNativeBuilder, AndroidGradleBuilder, IPhoneBuilder), which is the code cloud builds actually run. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/javase/BuildHintSchemaDefaults.java | 105 +-------- Ports/iOSPort/nativeSources/WATCHOS_PORT.md | 22 +- .../developer-guide/wearables.properties | 9 +- docs/developer-guide/TVPlatforms.asciidoc | 5 +- docs/developer-guide/Wearables.asciidoc | 136 ++++-------- .../builders/AndroidGradleBuilder.java | 30 +-- .../com/codename1/builders/IPhoneBuilder.java | 6 +- .../builders/WatchNativeBuilder.java | 114 +++++----- .../com/codename1/maven/CN1BuildMojo.java | 182 ++++++--------- .../builders/WatchNativeBuilderTest.java | 210 ++++++++++++++++++ .../CN1BuildMojoSecondaryEntryPointTest.java | 94 ++++++++ .../settings/CodenameOneSettings.java | 8 +- 12 files changed, 521 insertions(+), 400 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchNativeBuilderTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildMojoSecondaryEntryPointTest.java diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java index 6b35cb182ae..2dcd8d72e80 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java @@ -95,75 +95,12 @@ static void register() { + "Android theme. (Deprecated alias: cn1.androidTheme; " + "and.hololight=true is also accepted for back-compat.)"); - // watchOS native build (Apple Watch). Adds a watchOS app target to the - // iOS Xcode project, rendering the CN1 UI via the Core Graphics backend. - set("{{@watchNative}}.label", "Apple Watch (watchOS)"); - set("{{@watchNative}}.description", - "Builds an Apple Watch app from the same project, rendering the " - + "Codename One UI on watchOS via the Core Graphics backend. The " - + "watch app is a separate arm64_32 target; in the default " - + "companion mode it is embedded in the iOS .ipa and installs " - + "with the phone app."); - - set("{{#watchNative#watchNative.enabled}}.label", "Enable watchOS target"); - set("{{#watchNative#watchNative.enabled}}.type", "Select"); - set("{{#watchNative#watchNative.enabled}}.values", "false,true"); - set("{{#watchNative#watchNative.enabled}}.description", - "When true, adds an Apple Watch app target to the generated " - + "Xcode project. Also auto-enabled whenever codename1.watchMain " - + "is declared next to codename1.mainName in " - + "codenameone_settings.properties, so the double app is produced " - + "as part of the regular iPhone build. Requires the Ruby " - + "xcodeproj gem (bundled with CocoaPods)."); - - set("{{#watchNative#watchNative.mainClass}}.label", "Watch lifecycle class"); - set("{{#watchNative#watchNative.mainClass}}.type", "String"); - set("{{#watchNative#watchNative.mainClass}}.description", - "Fully-qualified watch entry/lifecycle class. Normally set via " - + "codename1.watchMain; this hint is an override. May equal the " - + "phone main class - a distinct class lets the watch slice " - + "tree-shake from its own root. Defaults to the phone main class " - + "when watchNative.enabled=true without a watch entry."); - - set("{{#watchNative#watchNative.distribution}}.label", "Distribution"); - set("{{#watchNative#watchNative.distribution}}.type", "Select"); - set("{{#watchNative#watchNative.distribution}}.values", "companion,standalone"); - set("{{#watchNative#watchNative.distribution}}.description", - "companion = the watch app is embedded in the iOS app and " - + "installs with it (WKCompanionAppBundleIdentifier pinned to " - + "the iOS bundle). standalone = an independent watch-only app."); - - set("{{#watchNative#watchNative.bundleId}}.label", "Watch bundle identifier"); - set("{{#watchNative#watchNative.bundleId}}.type", "String"); - set("{{#watchNative#watchNative.bundleId}}.description", - "Bundle id of the watch app. Defaults to .watchkitapp."); - - set("{{#watchNative#watchNative.minDeploymentTarget}}.label", "Minimum watchOS version"); - set("{{#watchNative#watchNative.minDeploymentTarget}}.type", "String"); - set("{{#watchNative#watchNative.minDeploymentTarget}}.description", - "WATCHOS_DEPLOYMENT_TARGET for the watch target. Defaults to 10.0 " - + "(single-target WKApplication apps + WidgetKit complications)."); - - set("{{#watchNative#watchNative.teamId}}.label", "Apple team id"); - set("{{#watchNative#watchNative.teamId}}.type", "String"); - set("{{#watchNative#watchNative.teamId}}.description", - "Development team for signing the watch target. Defaults to the " - + "iOS team id (ios.teamId / ios.release.teamId)."); - - set("{{#watchNative#watchNative.displayName}}.label", "Watch app name"); - set("{{#watchNative#watchNative.displayName}}.type", "String"); - set("{{#watchNative#watchNative.displayName}}.description", - "Name shown under the watch app icon. Defaults to the app display " - + "name (codename1.displayName), then the main class name."); - - set("{{#watchNative#watchNative.embedCompanion}}.label", "Embed in iOS app"); - set("{{#watchNative#watchNative.embedCompanion}}.type", "Select"); - set("{{#watchNative#watchNative.embedCompanion}}.values", "false,true"); - set("{{#watchNative#watchNative.embedCompanion}}.description", - "When true (companion distribution), adds the watch app as a build " - + "dependency of the iOS app so the pair archives together. Off by " - + "default so the iOS build is unaffected; enable it for a packaged " - + "companion submission."); + // The wearable build has no build hints: a project declares the watch + // lifecycle class as codename1.watchMain next to codename1.mainName and + // both the Apple Watch and the Wear OS app are built from that root. + // codename1.watchStandalone says the watch app ships on its own. Both + // are entry-point settings rather than build hints, so they are edited + // on the Basic page of the settings tool. // Apple TV native build (tvOS). tvOS has UIKit + Metal but no OpenGL ES, // so it is handled like the Mac Catalyst slice: Metal renderer + GL stub @@ -214,36 +151,6 @@ static void register() { "Name shown under the tvOS app icon. Defaults to the app display " + "name (codename1.displayName), then the main class name."); - // Wear OS native build (Android). A Wear OS app is a regular Android app - // that declares the watch hardware feature; the CN1 UI renders through - // the normal Android pipeline (no separate backend, unlike watchOS). - set("{{@androidWear}}.label", "Wear OS (Android)"); - set("{{@androidWear}}.description", - "Builds the Android app as a Wear OS app: declares the watch " - + "hardware feature, marks the app standalone (runs without a " - + "paired phone app) and raises the minimum SDK to the Wear OS 2.0 " - + "baseline (API 23). CN.isWatch() returns true at runtime via " - + "PackageManager.FEATURE_WATCH. Independent of the Apple Watch " - + "build; enable both to target both wearables."); - - set("{{#androidWear#android.wear}}.label", "Enable Wear OS build"); - set("{{#androidWear#android.wear}}.type", "Select"); - set("{{#androidWear#android.wear}}.values", "false,true"); - set("{{#androidWear#android.wear}}.description", - "When true, marks the Android build as a Wear OS app (manifest " - + "uses-feature android.hardware.type.watch, standalone meta-data, " - + "minimum SDK floor API 23). With the hint off the manifest is " - + "unchanged."); - - set("{{#androidWear#android.wear.standalone}}.label", "Standalone Wear app"); - set("{{#androidWear#android.wear.standalone}}.type", "Select"); - set("{{#androidWear#android.wear.standalone}}.values", "true,false"); - set("{{#androidWear#android.wear.standalone}}.description", - "Declares the Wear app standalone (com.google.android.wearable." - + "standalone), so it installs and runs directly on the watch " - + "without a companion phone app. Defaults to true. Only applies " - + "when android.wear=true."); - // Android TV / Google TV: the same APK plus manifest metadata (Leanback // launcher category + leanback feature + optional touchscreen) and a // generated 320x180 banner. CN.isTV() returns true at runtime. diff --git a/Ports/iOSPort/nativeSources/WATCHOS_PORT.md b/Ports/iOSPort/nativeSources/WATCHOS_PORT.md index 9133966b85c..0d13a517082 100644 --- a/Ports/iOSPort/nativeSources/WATCHOS_PORT.md +++ b/Ports/iOSPort/nativeSources/WATCHOS_PORT.md @@ -44,10 +44,16 @@ A CN1 project declares the watch entry point next to the phone main in codename1.mainName=com.example.MyApp # phone lifecycle ("main" class) codename1.watchMain=com.example.MyWatchApp # watch lifecycle (Apple Watch + Wear) ``` -`codename1.watchMain` flows through `CN1BuildMojo` as the `watchMain` build arg. -`WatchNativeBuilder.parseHints` auto-enables the watch slice whenever `watchMain` -is present (no separate `watchNative.enabled` needed), so the regular iPhone -build emits the packaged double app. +Declaring `codename1.watchMain` is the *entire* opt-in — there are no wearable +build hints. It reaches `WatchNativeBuilder.parseHints` as the `watchMain` build +argument by two routes: `CN1BuildMojo.putSecondaryEntryPointArguments` on local +builds, and, for cloud builds, `createAntProject` mirroring it into +`codename1.arg.watchMain` in the uploaded settings file (the server only lifts +`codename1.arg.*` keys, so without that mirror a cloud build produced no watch +app at all). Everything else — bundle id, deployment target, team id, display +name — is derived. The one other recognized setting is +`codename1.watchStandalone=true`, which ships the watch app on its own instead of +embedding it in the phone app. **Important - current bootstrap reality (do NOT assume watchMain tree-shaking):** The watch target compiles the SAME single ParparVM translation as the phone and @@ -73,9 +79,11 @@ Core-Graphics-backend issue, not absent code. - a Swift bridging header. Because the watch app is SwiftUI-`@main`-rooted, the shared ParparVM `int main()` -(the phone entry) must be excluded from the watch target via -`watchNative.phoneMainSource=` (added to the -watch target's `EXCLUDED_SOURCE_FILE_NAMES`). +(the phone entry) must not produce a second `main` symbol in the watch target. +`applyXcodeSettings` neutralises it with a per-file `-Dmain=...` rename on the +translated phone Stub, which keeps the app's translated classes available to the +watch. (An earlier draft of this document described a `watchNative.phoneMainSource` +hint that excluded the file outright; that hint never existed.) ## Complete interactive app on the simulator — VERIFIED (2026-06-17) diff --git a/docs/demos/common/src/main/snippets/developer-guide/wearables.properties b/docs/demos/common/src/main/snippets/developer-guide/wearables.properties index 1a1cd0d41e4..9d93449019f 100644 --- a/docs/demos/common/src/main/snippets/developer-guide/wearables.properties +++ b/docs/demos/common/src/main/snippets/developer-guide/wearables.properties @@ -1,13 +1,10 @@ // Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. // tag::wearables-properties-001[] -watchNative.enabled=true +codename1.mainName=MyApp +codename1.watchMain=com.mycompany.myapp.MyWatchMain // end::wearables-properties-001[] // tag::wearables-properties-002[] -codename1.watchMain=com.mycompany.myapp.MyWatchMain +codename1.watchStandalone=true // end::wearables-properties-002[] - -// tag::wearables-properties-003[] -android.wear=true -// end::wearables-properties-003[] diff --git a/docs/developer-guide/TVPlatforms.asciidoc b/docs/developer-guide/TVPlatforms.asciidoc index 7ba5bbcbe08..a0a91c32d2c 100644 --- a/docs/developer-guide/TVPlatforms.asciidoc +++ b/docs/developer-guide/TVPlatforms.asciidoc @@ -87,8 +87,9 @@ feature, makes `android.hardware.touchscreen` optional, and generates the === Building for Apple TV (tvOS) -Enable the tvOS application target with the `tvNative.*` build hints (analogous -to the `watchNative.*` hints used for Apple Watch): +Enable the tvOS application target with the `tvNative.*` build hints (the Apple +Watch build is enabled by declaring a `codename1.watchMain` instead -- see the +wearables chapter): [source,properties] ---- diff --git a/docs/developer-guide/Wearables.asciidoc b/docs/developer-guide/Wearables.asciidoc index 5eb188145b2..57d5202cf40 100644 --- a/docs/developer-guide/Wearables.asciidoc +++ b/docs/developer-guide/Wearables.asciidoc @@ -77,70 +77,50 @@ image::img/wearables/apple-watch-simulator.png[Codename One running on the Apple ==== Enabling the watchOS Build -Set the build hint: +Declare the watch lifecycle class next to your phone main class in +`codenameone_settings.properties`: [source,properties] ---- include::../demos/common/src/main/snippets/developer-guide/wearables.properties[tag=wearables-properties-001,indent=0] ---- -Alternatively, declare a watch entry point and the watch slice is produced -automatically as part of the regular iOS build: +That's the whole opt-in. There are no wearable build hints: the watch bundle +identifier, deployment target, signing team and display name are all derived from +the settings your project already has. The watch app is built as part of the +regular iOS build and embedded in the phone app, so the pair installs together. + +Note the asymmetry: `codename1.mainName` is a simple class name resolved against +`codename1.packageName`, while `codename1.watchMain` is fully qualified. + +==== Standalone Watch Apps + +By default the watch app is a companion: it ships inside the phone app. If the +watch app is the product and there is no phone app to pair with, declare it +standalone: [source,properties] ---- include::../demos/common/src/main/snippets/developer-guide/wearables.properties[tag=wearables-properties-002,indent=0] ---- -If you don't declare a distinct `watchMain`, the watch app reuses your phone -main class as its lifecycle entry point. - -NOTE: `codename1.watchMain` (and `watchNative.enabled`) affect only the Apple -Watch (watchOS) build. They have no effect on Android: a Wear OS build is never -produced implicitly -- you enable it explicitly with `android.wear=true` (see -<>). A project can target both wearables at once by setting a -`watchMain` (or `watchNative.enabled=true`) and `android.wear=true` together. +A standalone build produces a watch-only product on Apple, and on Android turns +the single APK into the Wear OS app. -==== watchOS Build Hints +==== Wearable Settings [cols="2,1,4"] |=== -|Build hint |Default |Description +|Setting |Default |Description -|`watchNative.enabled` -|`false` -|Force the watch target on even without a distinct `watchMain`. - -|`codename1.watchMain` (a.k.a. `watchMain`) +|`codename1.watchMain` |_(none)_ -|Fully-qualified watch lifecycle entry class. Setting it also turns on the watch -build. - -|`watchNative.distribution` -|`companion` -|`companion` embeds the watch app in the iOS app; `standalone` builds a -watch-only app with no paired phone app. +|Fully-qualified watch lifecycle entry class. Declaring it builds the watch app +on both Apple Watch and Wear OS. -|`watchNative.bundleId` -|`.watchkitapp` -|Bundle identifier of the watch app. - -|`watchNative.minDeploymentTarget` -|`10.0` -|`WATCHOS_DEPLOYMENT_TARGET` for the watch target. - -|`watchNative.displayName` -|_(app display name)_ -|The watch app name shown on the watch. - -|`watchNative.teamId` -|_(falls back to the iOS team id)_ -|Apple Developer Team ID used to sign the watch target. - -|`watchNative.embedCompanion` +|`codename1.watchStandalone` |`false` -|Embed the watch app into the iOS app as a build dependency. Off by default so -the iOS build is unaffected; enable it for a packaged companion submission. +|The watch app ships on its own rather than inside the phone app. |=== ==== Supported and Unsupported APIs on watchOS @@ -165,69 +145,43 @@ so keep watch screens light. ==== Building and Debugging -A `companion` build produces an iOS `.ipa` that carries the embedded watch app; -a `standalone` build produces a watch-only product. The generated project is a -standard Xcode project, so you can open it and debug/profile the watch target -with the native Xcode tools as usual. Cloud builds support the watch target -through the same iOS build -- set the hints above and build for iOS. +A companion build produces an iOS `.ipa` that carries the embedded watch app; a +standalone build produces a watch-only product. The generated project is a +standard Xcode project, so you can open it and debug or profile the watch target +with the native Xcode tools as usual. Cloud builds produce the watch app through +the same iOS build -- declare the watch main class and build for iOS. === Android (Wear OS) [[wear-os-android]] A Wear OS app is a regular Android app. The Codename One Android port renders the UI with the same pipeline it uses on phones, so no special rendering backend is -required -- you only need to mark the build as a watch app. - -==== Enabling the Wear OS Build - -[source,properties] ----- -include::../demos/common/src/main/snippets/developer-guide/wearables.properties[tag=wearables-properties-003,indent=0] ----- +required. The same `codename1.watchMain` that builds the Apple Watch app builds +the Wear OS app, so a project targets both wearables from one declaration. -This injects the watch hardware feature into the manifest: +A standalone Wear app declares the watch hardware feature in the manifest: [source,xml] ---- include::../demos/common/src/main/snippets/developer-guide/wearables.xml[tag=wearables-xml-001,indent=0] ---- -By default it also declares the app *standalone*, so it installs and runs -directly on the watch without a paired phone app: +It also marks itself standalone, so it installs and runs directly on the watch +without a paired phone app: [source,xml] ---- include::../demos/common/src/main/snippets/developer-guide/wearables.xml[tag=wearables-xml-002,indent=0] ---- -Setting `android.wear=true` also raises the minimum SDK to API 23 (the Wear OS -2.0 standalone baseline) if your project requests a lower level. - -==== Wear OS Build Hints - -[cols="2,1,4"] -|=== -|Build hint |Default |Description - -|`android.wear` -|`false` -|Mark the build as a Wear OS app (manifest feature + standalone meta-data + -minimum SDK floor). - -|`android.wear.standalone` -|`true` -|Declare the app standalone. Set to `false` for a watch app that requires a -companion phone app. - -|`android.playService.wearable` -|`false` -|Add the `play-services-wearable` dependency (only needed if you use the -Wearable Data Layer / message APIs directly). -|=== +A standalone Wear build also raises the minimum SDK to API 23, the Wear OS 2.0 +standalone baseline, if your project requests a lower level. TIP: Because a Wear OS app is an ordinary Android app, you can also declare any additional manifest features and permissions with the generic -`android.uses_feature.` and `android.uses_permission.` hints. +`android.uses_feature.` and `android.uses_permission.` hints. The +`android.playService.wearable` hint adds the `play-services-wearable` dependency +if you want to call the Wearable Data Layer APIs directly. === Summary @@ -236,21 +190,21 @@ additional manifest features and permissions with the generic | |Apple Watch (watchOS) |Wear OS (Android) |Enable -|`watchNative.enabled=true` or `codename1.watchMain` -|`android.wear=true` +|`codename1.watchMain` +|`codename1.watchMain` |Rendering |Dedicated Core Graphics backend + separate watch target |Standard Android rendering pipeline |Distribution -|Companion (embedded in iOS app) or standalone -|Standalone (default) or companion +|Companion (embedded in the phone app) or standalone +|Companion or standalone |Runtime detection |`CN.isWatch()` |`CN.isWatch()` |=== -The wearable build is additive on both platforms: with the hints off, your phone -builds are unchanged. +The wearable build is additive on both platforms: without a watch main class, +your phone builds are unchanged. 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 7c541eb89b8..38aa3dc7e0f 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 @@ -1253,25 +1253,29 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc String googlePlayAdViewCode = ""; String userXapplication = request.getArg("android.xapplication", ""); - // Wear OS support. android.wear=true marks this as an Android Wear - // (Wear OS) app. A Wear app is a regular Android app that declares the - // watch hardware feature; the Codename One UI renders through the same - // Android pipeline (no separate render backend is needed, unlike the - // Apple Watch port), and CN.isWatch() returns true at runtime via - // PackageManager.FEATURE_WATCH. Standalone Wear apps (the default since - // Wear OS 2.0) install and run directly on the watch without a paired - // phone app. With the hint off the manifest is unchanged. + // Wear OS support, driven by the same entry point as the Apple Watch + // build: a project declares a watch lifecycle class with + // codename1.watchMain and gets a watch app on both platforms. A Wear app + // is a regular Android app that declares the watch hardware feature; the + // Codename One UI renders through the same Android pipeline (no separate + // render backend is needed, unlike the Apple Watch port), and + // CN.isWatch() returns true at runtime via PackageManager.FEATURE_WATCH. + // + // codename1.watchStandalone=true means the watch app IS the product: it + // installs and runs directly on the watch with no paired phone app, so + // this single APK becomes the watch app. Without it the watch app is a + // companion to the phone app and ships as its own artifact, which leaves + // this (phone) manifest untouched. String wearApplicationMetaData = ""; - if ("true".equals(request.getArg("android.wear", "false"))) { + String watchMain = request.getArg("watchMain", "").trim(); + boolean watchStandalone = "true".equals(request.getArg("watchStandalone", "false")); + if (watchMain.length() > 0 && watchStandalone) { // Wear OS 2.0 (the standalone-app baseline) is API 23. minSDK = maxInt("23", minSDK); if (!xPermissions.contains("android.hardware.type.watch")) { xPermissions += " \n"; } - // Declare the app standalone (runs without a companion phone app) - // unless the developer opts out or already declared the meta-data. - if (!"false".equals(request.getArg("android.wear.standalone", "true")) - && !userXapplication.contains("com.google.android.wearable.standalone")) { + if (!userXapplication.contains("com.google.android.wearable.standalone")) { wearApplicationMetaData = " \n"; } } 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 c74849d71de..ea285f42feb 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 @@ -60,9 +60,9 @@ public class IPhoneBuilder extends Executor { // that is an implementation detail -- never surfaced in hint names. private final MacNativeBuilder macNativeBuilder = new MacNativeBuilder(this); - // watchNative.* delegate: adds an Apple Watch (watchOS) target rendered via - // the Core Graphics backend. Like macNativeBuilder this is inert unless the - // watchNative.enabled hint is set, keeping the iOS build unchanged. + // Watch delegate: adds an Apple Watch (watchOS) target rendered via the Core + // Graphics backend. Like macNativeBuilder this is inert unless the project + // declares a codename1.watchMain, keeping the iOS build unchanged. private final WatchNativeBuilder watchNativeBuilder = new WatchNativeBuilder(this); // tvNative.* delegate: adds an Apple TV (tvOS) target. tvOS is handled like 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 5a097e6cc8b..7ccd453b1e8 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 @@ -30,40 +30,45 @@ /** * Helper extracted from {@link IPhoneBuilder} that owns the Apple Watch - * (watchOS) native build path. Activated by the build hint {@code - * watchNative.enabled=true}. + * (watchOS) native build path. Activated by the project declaring a watch + * lifecycle class, {@code codename1.watchMain}; there are no other watch build + * hints, everything else is derived. * *

Unlike {@link MacNativeBuilder} (which Mac-Catalyst-slices the SAME iOS app * target), a watchOS app is a distinct product: it has its own bundle, its own * {@code WKApplication} Info.plist, and the {@code arm64_32} architecture. So * this builder adds a second Xcode target to the generated project, - * compiles the shared ParparVM-generated sources (minus the GL/Metal-only files) - * for watchOS, and - in the default {@code companion} distribution - embeds the - * watch app inside the iOS {@code .app} via an "Embed Watch Content" copy-files - * phase. The watch UI is rendered by the Core Graphics backend - * ({@code CN1CGGraphics} + {@code CN1WatchRenderingView}) driven by + * compiles the ParparVM-generated sources (minus the GL/Metal-only files) for + * watchOS, and embeds the watch app inside the iOS {@code .app} via an "Embed + * Watch Content" copy-files phase so the pair installs together. A project that + * sets {@code codename1.watchStandalone=true} ships a watch-only product with no + * paired phone app instead. The watch UI is rendered by the Core Graphics + * backend ({@code CN1CGGraphics} + {@code CN1WatchRenderingView}) driven by * {@code CN1WatchHost}. * *

The underlying mechanism is a Ruby {@code xcodeproj} script (same toolchain * macNative relies on). Like {@link MacNativeBuilder} this is a delegate owned * by {@link IPhoneBuilder}, invoked at hint-parse time and at the - * post-project-generate patching point. Every change is additive: with the hint - * off, the iOS build is byte-for-byte unchanged. + * post-project-generate patching point. Every change is additive: without a + * {@code watchMain} the iOS build is byte-for-byte unchanged. */ class WatchNativeBuilder { private final IPhoneBuilder owner; - // Parsed hints. + // 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"; + + // Derived build state. private boolean enabled; - private String distribution; // companion | standalone + private boolean standalone; // codename1.watchStandalone private String bundleId; - private String minDeploymentTarget; // WATCHOS_DEPLOYMENT_TARGET private String teamId; private String displayName; - // Fully-qualified watch lifecycle entry class (codename1.watchMain). May - // equal the phone main class; a distinct value lets the watch slice tree- - // shake from its own root. Empty when neither watchMain nor an explicit - // watchNative.mainClass hint is set (then we fall back to the phone main). + // Fully-qualified watch lifecycle entry class (codename1.watchMain). Its + // presence is what turns the watch build on, and it is the root the watch + // slice is translated from. Empty when the project declares no watch app. private String watchMain; // GL/Metal-only source files with no watchOS substitute. Excluded from the @@ -124,50 +129,34 @@ boolean isEnabled() { } /** - * Parse the {@code watchNative.*} hint family. Caller flips Metal on (the - * watch slice cannot use GL ES; the iOS slice still wants Metal) and raises - * the watch deployment floor. + * Resolve the watch build from the project's entry points. The watch app is + * built whenever the project declares a watch lifecycle class + * ({@code codenameone_settings.properties -> codename1.watchMain}, arriving + * here as the {@code watchMain} argument); everything else is derived. The + * only other recognized setting is {@code codename1.watchStandalone}, which + * says the watch app ships on its own rather than inside the phone app -- + * the one thing that cannot be inferred from the project. + * + *

Caller flips Metal on (the watch slice cannot use GL ES; the iOS slice + * still wants Metal) and raises the watch deployment floor. */ void parseHints(BuildRequest request) { - // The watch slice auto-enables when the project declares a watchMain - // entry point (codenameone_settings.properties -> codename1.watchMain), - // so the double app is produced seamlessly as part of the regular iPhone - // build. watchNative.enabled=true forces it on even without a distinct - // watchMain (the watch then shares the phone main class). - watchMain = request.getArg("watchMain", - request.getArg("watchNative.mainClass", "")).trim(); - enabled = "true".equals(request.getArg("watchNative.enabled", "false")) - || watchMain.length() > 0; + watchMain = request.getArg("watchMain", "").trim(); + enabled = watchMain.length() > 0; if (!enabled) { return; } - if (watchMain.length() == 0) { - // No distinct watch entry: reuse the phone main class as the watch - // lifecycle root. - watchMain = request.getMainClass(); - } - distribution = request.getArg("watchNative.distribution", "companion"); - bundleId = request.getArg("watchNative.bundleId", - request.getPackageName() + ".watchkitapp"); - // watchOS 10 is the floor: single-target WKApplication apps, WidgetKit - // complications, and the SwiftUI onChange(of:) two-parameter API the - // generated CN1WatchRootView uses. Lower only if the project explicitly - // asks (and adjusts the generated shell accordingly). - minDeploymentTarget = request.getArg("watchNative.minDeploymentTarget", "10.0"); - teamId = request.getArg("watchNative.teamId", - request.getArg("ios.release.teamId", - request.getArg("ios.teamId", - request.getArg("ios.debug.teamId", "")))); - displayName = request.getArg("watchNative.displayName", - request.getDisplayName() != null ? request.getDisplayName() : request.getMainClass()); + standalone = "true".equals(request.getArg("watchStandalone", "false")); + bundleId = request.getPackageName() + ".watchkitapp"; + teamId = request.getArg("ios.release.teamId", + request.getArg("ios.teamId", + request.getArg("ios.debug.teamId", ""))); + displayName = request.getDisplayName() != null + ? request.getDisplayName() : request.getMainClass(); } boolean isStandalone() { - return "standalone".equalsIgnoreCase(distribution); - } - - String getMinDeploymentTarget() { - return minDeploymentTarget; + return standalone; } /** Fully-qualified watch lifecycle entry class. */ @@ -445,7 +434,7 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) String watchTargetName = mainClass + "Watch"; String projectFile = new File(tmpFile, "dist/" + mainClass + ".xcodeproj").getAbsolutePath(); String infoPlistPath = mainClass + "-src/" + mainClass + "-Watch-Info.plist"; - String resolvedTeamId = owner.sanitizeTeamId(teamId, "watchNative.teamId"); + String resolvedTeamId = owner.sanitizeTeamId(teamId, "ios.teamId"); StringBuilder excluded = new StringBuilder(); for (String f : EXCLUDED_WATCH_SOURCES) { @@ -473,7 +462,7 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) .append("watch_target = xcproj.targets.find { |t| t.name == watch_name }\n") .append("if watch_target.nil?\n") .append(" watch_target = xcproj.new_target(:application, watch_name, :watchos, '") - .append(IPhoneBuilder.escapeRubyStr(minDeploymentTarget)).append("')\n") + .append(IPhoneBuilder.escapeRubyStr(MIN_DEPLOYMENT_TARGET)).append("')\n") .append("end\n") // Compile the shared ParparVM sources for the watch, minus the // GL/Metal-only files. Reuse the app target's compile sources so @@ -509,7 +498,7 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) .append(" bs['ARCHS[sdk=watchos*]'] = 'arm64_32'\n") .append(" bs['ARCHS[sdk=watchsimulator*]'] = '$(ARCHS_STANDARD)'\n") .append(" bs['WATCHOS_DEPLOYMENT_TARGET'] = '") - .append(IPhoneBuilder.escapeRubyStr(minDeploymentTarget)).append("'\n") + .append(IPhoneBuilder.escapeRubyStr(MIN_DEPLOYMENT_TARGET)).append("'\n") .append(" bs['TARGETED_DEVICE_FAMILY'] = '4'\n") .append(" bs['PRODUCT_BUNDLE_IDENTIFIER'] = '") .append(IPhoneBuilder.escapeRubyStr(bundleId)).append("'\n") @@ -599,13 +588,12 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) .append(" end\n") .append("end\n"); - // Companion embedding is opt-in (watchNative.embedCompanion=true) and OFF - // by default. Embedding adds the watch target as a build dependency of the - // iOS app, which makes building the iOS app also build the watch target. - // Remove any dependency/copy phase that Xcode or an older generator run - // left behind unless the project explicitly asks for companion packaging. - boolean embedCompanion = "true".equals(request.getArg("watchNative.embedCompanion", "false")); - if (!embedCompanion || isStandalone()) { + // A companion watch app is embedded in the iOS app so the pair installs + // together -- that is the whole point of declaring a watchMain next to a + // phone main, so it is not opt-in. A standalone watch app ships on its + // own instead, so strip any dependency/copy phase Xcode or an earlier + // generator run left behind. + if (isStandalone()) { s.append("app_target.dependencies.to_a.each do |dep|\n") .append(" proxy = dep.respond_to?(:target_proxy) ? dep.target_proxy : nil\n") .append(" remote = proxy && proxy.respond_to?(:remote_global_id) ? xcproj.objects_by_uuid[proxy.remote_global_id] : nil\n") @@ -659,7 +647,7 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) } owner.log("[watchNative] Added watchOS target " + watchTargetName + " (" + (isStandalone() ? "standalone" : "companion") + ", " - + "watchOS " + minDeploymentTarget + ", arm64_32)"); + + "watchOS " + MIN_DEPLOYMENT_TARGET + ", arm64_32)"); } catch (BuildException ex) { throw ex; } catch (Exception ex) { 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 2381600d480..22a4568c7e9 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 @@ -613,6 +613,66 @@ private File getStringsJar() throws IOException { public static final String BUILD_TARGET_MAC_NATIVE = Executor.BUILD_TARGET_MAC_NATIVE; public static final String BUILD_TARGET_LINUX_NATIVE = Executor.BUILD_TARGET_LINUX_NATIVE; + /** + * The entry points a project can declare besides {@code codename1.mainName}, + * mapped to the build argument each one becomes. A project with a + * {@code codename1.watchMain} gets an Apple Watch and a Wear OS app built + * from that root; {@code codename1.tvMain} does the same for tvOS. The + * accompanying {@code codename1.watchStandalone} says the watch app ships on + * its own rather than alongside the phone app. + * + *

These ride the extensible build-argument map rather than the + * {@link BuildRequest} wire format, so adding an entry point needs no + * protocol change. + */ + private static final Map SECONDARY_ENTRY_POINTS; + static { + Map m = new LinkedHashMap(); + m.put("codename1.watchMain", "watchMain"); + m.put("codename1.watchStandalone", "watchStandalone"); + m.put("codename1.tvMain", "tvMain"); + SECONDARY_ENTRY_POINTS = Collections.unmodifiableMap(m); + } + + /** + * Copies the secondary entry points declared in the project settings onto a + * local {@link BuildRequest}. The cloud path does the equivalent by mirroring + * them into the {@code codename1.arg.} namespace of the uploaded settings + * file, so both paths hand the builders the same arguments. + * + * @param r the request being assembled + * @param props the project's codenameone_settings.properties + */ + private static void putSecondaryEntryPointArguments(BuildRequest r, Properties props) { + for (Map.Entry entry : SECONDARY_ENTRY_POINTS.entrySet()) { + String value = props.getProperty(entry.getKey()); + if (value != null && value.trim().length() > 0) { + r.putArgument(entry.getValue(), value.trim()); + } + } + } + + /** + * Copies the secondary entry points into the {@code codename1.arg.} namespace + * of the settings file that is uploaded to the build server. + * + *

They are declared without that prefix because they sit next to + * {@code codename1.mainName} and that is the shape developers expect. The + * server, however, only lifts {@code codename1.arg.*} keys out of the + * uploaded file, so without this mirror a cloud build never learns that the + * project has a watch or TV app and silently produces neither. + * + * @param props the settings being prepared for upload, mutated in place + */ + static void mirrorSecondaryEntryPointsToBuildArgs(Properties props) { + for (Map.Entry entry : SECONDARY_ENTRY_POINTS.entrySet()) { + String value = props.getProperty(entry.getKey()); + if (value != null && value.trim().length() > 0) { + props.setProperty("codename1.arg." + entry.getValue(), value.trim()); + } + } + } + private static boolean isLocalBuildTarget(String buildTarget) { if (buildTarget == null) { return false; @@ -882,6 +942,8 @@ private void createAntProject() throws IOException, LibraryPropertiesException, cn1SettingsProps.setProperty("codename1.arg.maven.codenameone-core.version", cn1MavenVersion); cn1SettingsProps.setProperty("codename1.arg.maven.codenameone-maven-plugin", cn1MavenPluginVersion); + mirrorSecondaryEntryPointsToBuildArgs(cn1SettingsProps); + // App-extension provisioning profiles (e.g. the generated CN1Widgets WidgetKit // extension) are named by the codename1.ios.appext..provision setting, which // points at a local .mobileprovision file. Cloud builds have no folder to drop the @@ -1194,29 +1256,7 @@ private void doAndroidLocalBuild(File tmpProjectDir, Properties props, File dist r.setDisplayName(props.getProperty("codename1.displayName")); r.setPackageName(props.getProperty("codename1.packageName")); r.setMainClass(props.getProperty("codename1.mainName")); - // watchMain: optional separate lifecycle entry point for the Apple Watch - // / Wear OS slice, declared next to codename1.mainName in - // codenameone_settings.properties as codename1.watchMain. May legally - // point at the same class as mainName, but a distinct entry point lets - // the watch slice tree-shake more aggressively. Passed as a build arg so - // it rides the extensible args map (no BuildRequest wire-format change) - // and reaches WatchNativeBuilder via request.getArg("watchMain"). - { - String cn1WatchMain = props.getProperty("codename1.watchMain"); - if (cn1WatchMain != null && cn1WatchMain.trim().length() > 0) { - r.putArgument("watchMain", cn1WatchMain.trim()); - } - } - // tvMain: optional separate lifecycle entry point for the Apple TV - // (tvOS) slice, declared as codename1.tvMain. Like watchMain it may - // point at the same class as mainName; a distinct value also auto-enables - // the tvOS target. Reaches TvNativeBuilder via request.getArg("tvMain"). - { - String cn1TvMain = props.getProperty("codename1.tvMain"); - if (cn1TvMain != null && cn1TvMain.trim().length() > 0) { - r.putArgument("tvMain", cn1TvMain.trim()); - } - } + putSecondaryEntryPointArguments(r, props); r.setVersion(props.getProperty("codename1.version")); String iconPath = props.getProperty("codename1.icon"); File iconFile = new File(iconPath); @@ -1456,29 +1496,7 @@ private void doIOSLocalBuild(File tmpProjectDir, Properties props, File distJar) r.setDisplayName(props.getProperty("codename1.displayName")); r.setPackageName(props.getProperty("codename1.packageName")); r.setMainClass(props.getProperty("codename1.mainName")); - // watchMain: optional separate lifecycle entry point for the Apple Watch - // / Wear OS slice, declared next to codename1.mainName in - // codenameone_settings.properties as codename1.watchMain. May legally - // point at the same class as mainName, but a distinct entry point lets - // the watch slice tree-shake more aggressively. Passed as a build arg so - // it rides the extensible args map (no BuildRequest wire-format change) - // and reaches WatchNativeBuilder via request.getArg("watchMain"). - { - String cn1WatchMain = props.getProperty("codename1.watchMain"); - if (cn1WatchMain != null && cn1WatchMain.trim().length() > 0) { - r.putArgument("watchMain", cn1WatchMain.trim()); - } - } - // tvMain: optional separate lifecycle entry point for the Apple TV - // (tvOS) slice, declared as codename1.tvMain. Like watchMain it may - // point at the same class as mainName; a distinct value also auto-enables - // the tvOS target. Reaches TvNativeBuilder via request.getArg("tvMain"). - { - String cn1TvMain = props.getProperty("codename1.tvMain"); - if (cn1TvMain != null && cn1TvMain.trim().length() > 0) { - r.putArgument("tvMain", cn1TvMain.trim()); - } - } + putSecondaryEntryPointArguments(r, props); r.setVersion(props.getProperty("codename1.version")); String iconPath = props.getProperty("codename1.icon"); File iconFile = new File(iconPath); @@ -1585,29 +1603,7 @@ private void doWindowsNativeLocalBuild(File tmpProjectDir, Properties props, Fil r.setDisplayName(props.getProperty("codename1.displayName")); r.setPackageName(props.getProperty("codename1.packageName")); r.setMainClass(props.getProperty("codename1.mainName")); - // watchMain: optional separate lifecycle entry point for the Apple Watch - // / Wear OS slice, declared next to codename1.mainName in - // codenameone_settings.properties as codename1.watchMain. May legally - // point at the same class as mainName, but a distinct entry point lets - // the watch slice tree-shake more aggressively. Passed as a build arg so - // it rides the extensible args map (no BuildRequest wire-format change) - // and reaches WatchNativeBuilder via request.getArg("watchMain"). - { - String cn1WatchMain = props.getProperty("codename1.watchMain"); - if (cn1WatchMain != null && cn1WatchMain.trim().length() > 0) { - r.putArgument("watchMain", cn1WatchMain.trim()); - } - } - // tvMain: optional separate lifecycle entry point for the Apple TV - // (tvOS) slice, declared as codename1.tvMain. Like watchMain it may - // point at the same class as mainName; a distinct value also auto-enables - // the tvOS target. Reaches TvNativeBuilder via request.getArg("tvMain"). - { - String cn1TvMain = props.getProperty("codename1.tvMain"); - if (cn1TvMain != null && cn1TvMain.trim().length() > 0) { - r.putArgument("tvMain", cn1TvMain.trim()); - } - } + putSecondaryEntryPointArguments(r, props); r.setVersion(props.getProperty("codename1.version")); r.setVendor(props.getProperty("codename1.vendor")); r.setType("windows"); @@ -1688,29 +1684,7 @@ private void doLinuxNativeLocalBuild(File tmpProjectDir, Properties props, File r.setDisplayName(props.getProperty("codename1.displayName")); r.setPackageName(props.getProperty("codename1.packageName")); r.setMainClass(props.getProperty("codename1.mainName")); - // watchMain: optional separate lifecycle entry point for the Apple Watch - // / Wear OS slice, declared next to codename1.mainName in - // codenameone_settings.properties as codename1.watchMain. May legally - // point at the same class as mainName, but a distinct entry point lets - // the watch slice tree-shake more aggressively. Passed as a build arg so - // it rides the extensible args map (no BuildRequest wire-format change) - // and reaches WatchNativeBuilder via request.getArg("watchMain"). - { - String cn1WatchMain = props.getProperty("codename1.watchMain"); - if (cn1WatchMain != null && cn1WatchMain.trim().length() > 0) { - r.putArgument("watchMain", cn1WatchMain.trim()); - } - } - // tvMain: optional separate lifecycle entry point for the Apple TV - // (tvOS) slice, declared as codename1.tvMain. Like watchMain it may - // point at the same class as mainName; a distinct value also auto-enables - // the tvOS target. Reaches TvNativeBuilder via request.getArg("tvMain"). - { - String cn1TvMain = props.getProperty("codename1.tvMain"); - if (cn1TvMain != null && cn1TvMain.trim().length() > 0) { - r.putArgument("tvMain", cn1TvMain.trim()); - } - } + putSecondaryEntryPointArguments(r, props); r.setVersion(props.getProperty("codename1.version")); r.setVendor(props.getProperty("codename1.vendor")); r.setType("linux"); @@ -1764,29 +1738,7 @@ private void doJavaScriptLocalBuild(File tmpProjectDir, Properties props, File d r.setDisplayName(props.getProperty("codename1.displayName")); r.setPackageName(props.getProperty("codename1.packageName")); r.setMainClass(props.getProperty("codename1.mainName")); - // watchMain: optional separate lifecycle entry point for the Apple Watch - // / Wear OS slice, declared next to codename1.mainName in - // codenameone_settings.properties as codename1.watchMain. May legally - // point at the same class as mainName, but a distinct entry point lets - // the watch slice tree-shake more aggressively. Passed as a build arg so - // it rides the extensible args map (no BuildRequest wire-format change) - // and reaches WatchNativeBuilder via request.getArg("watchMain"). - { - String cn1WatchMain = props.getProperty("codename1.watchMain"); - if (cn1WatchMain != null && cn1WatchMain.trim().length() > 0) { - r.putArgument("watchMain", cn1WatchMain.trim()); - } - } - // tvMain: optional separate lifecycle entry point for the Apple TV - // (tvOS) slice, declared as codename1.tvMain. Like watchMain it may - // point at the same class as mainName; a distinct value also auto-enables - // the tvOS target. Reaches TvNativeBuilder via request.getArg("tvMain"). - { - String cn1TvMain = props.getProperty("codename1.tvMain"); - if (cn1TvMain != null && cn1TvMain.trim().length() > 0) { - r.putArgument("tvMain", cn1TvMain.trim()); - } - } + putSecondaryEntryPointArguments(r, props); r.setVersion(props.getProperty("codename1.version")); String iconPath = props.getProperty("codename1.icon"); if (iconPath != null) { 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 new file mode 100644 index 00000000000..53b957625f1 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchNativeBuilderTest.java @@ -0,0 +1,210 @@ +/* + * 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 java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Pins the watch build's contract with the project: declaring a watch lifecycle +/// class is the entire opt-in, everything else is derived, and a project that +/// declares none must be left completely alone. The wearable build deliberately +/// carries no build hints, so these tests also guard against re-introducing one +/// by accident. +class WatchNativeBuilderTest { + + private static final String WATCH_MAIN = "com.mycompany.myapp.MyWatchMain"; + + // ------------------------------------------------------------------ + // Enablement + // ------------------------------------------------------------------ + + @Test + void projectWithoutAWatchMainBuildsNoWatchApp() { + WatchNativeBuilder b = parse(request()); + assertFalse(b.isEnabled(), + "A project that declares no watch lifecycle class must leave the iOS build untouched"); + } + + @Test + void declaringAWatchMainIsTheEntireOptIn() { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + + WatchNativeBuilder b = parse(req); + + assertTrue(b.isEnabled()); + assertEquals(WATCH_MAIN, b.getWatchMain()); + } + + @Test + void retiredEnablementHintsAreIgnored() { + // These named the old hint surface. Nothing may resurrect the watch + // build without a watch lifecycle class to root it at. + BuildRequest req = request(); + req.putArgument("watchNative.enabled", "true"); + req.putArgument("watchNative.mainClass", WATCH_MAIN); + + assertFalse(parse(req).isEnabled()); + } + + @Test + void blankWatchMainBuildsNoWatchApp() { + BuildRequest req = request(); + req.putArgument("watchMain", " "); + + assertFalse(parse(req).isEnabled()); + } + + // ------------------------------------------------------------------ + // Distribution + // ------------------------------------------------------------------ + + @Test + void watchAppIsACompanionByDefault() { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + + assertFalse(parse(req).isStandalone()); + } + + @Test + void watchStandaloneMakesTheWatchAppTheProduct() { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("watchStandalone", "true"); + + assertTrue(parse(req).isStandalone()); + } + + // ------------------------------------------------------------------ + // Info.plist + // ------------------------------------------------------------------ + + @Test + void companionPlistPinsTheWatchAppToThePhoneApp(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + + String plist = writeInfoPlist(req, tmp); + + assertTrue(plist.contains("WKApplication"), + "Modern single-target watch apps are marked with WKApplication"); + assertTrue(plist.contains("WKCompanionAppBundleIdentifier"), + "A companion watch app installs with the phone app it names"); + assertTrue(plist.contains("com.mycompany.myapp")); + } + + @Test + void standalonePlistNamesNoCompanion(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("watchStandalone", "true"); + + String plist = writeInfoPlist(req, tmp); + + assertTrue(plist.contains("WKApplication")); + assertFalse(plist.contains("WKCompanionAppBundleIdentifier"), + "A standalone watch app has no phone app to pair with"); + } + + @Test + void plistUsesTheProjectDisplayNameAndVersion(@TempDir Path tmp) throws IOException { + // Derived rather than configured: the watch app name and version come + // from the settings the project already has. + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + + String plist = writeInfoPlist(req, tmp); + + assertTrue(plist.contains("My App")); + assertTrue(plist.contains("2.5")); + } + + // ------------------------------------------------------------------ + // Generated entry point + // ------------------------------------------------------------------ + + @Test + void watchEntryPointIsGenerated(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + WatchNativeBuilder b = parse(req); + + File dir = tmp.toFile(); + b.writeWatchEntry(req, dir); + + String swift = read(new File(dir, "CN1WatchApp.swift")); + assertTrue(swift.contains("@main"), "The watch app is rooted in a SwiftUI @main shell"); + assertTrue(swift.contains("#if os(watchOS)"), + "The shell is globbed into the iOS target too, so it must compile away there"); + assertTrue(swift.contains("digitalCrownRotation")); + + String bootstrap = read(new File(dir, "CN1WatchBootstrap.m")); + assertTrue(bootstrap.contains("#if TARGET_OS_WATCH")); + assertTrue(bootstrap.contains("cn1_watch_app_main")); + assertTrue(bootstrap.contains(WATCH_MAIN), + "The bootstrap starts the runtime at the declared watch lifecycle class"); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private static BuildRequest request() { + BuildRequest req = new BuildRequest(); + req.setMainClass("MyApp"); + req.setPackageName("com.mycompany.myapp"); + req.setDisplayName("My App"); + req.setVersion("2.5"); + return req; + } + + private static WatchNativeBuilder parse(BuildRequest req) { + WatchNativeBuilder b = new WatchNativeBuilder(new IPhoneBuilder()); + b.parseHints(req); + return b; + } + + private static String writeInfoPlist(BuildRequest req, Path tmp) throws IOException { + WatchNativeBuilder b = parse(req); + File dir = tmp.toFile(); + b.writeWatchInfoPlist(req, dir); + return read(new File(dir, req.getMainClass() + "-Watch-Info.plist")); + } + + private static String read(File f) throws IOException { + if (!f.exists()) { + throw new AssertionError("Expected generated file was not written: " + f); + } + return new String(Files.readAllBytes(f.toPath())); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildMojoSecondaryEntryPointTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildMojoSecondaryEntryPointTest.java new file mode 100644 index 00000000000..03bb06e4d32 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildMojoSecondaryEntryPointTest.java @@ -0,0 +1,94 @@ +/* + * 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.Test; + +import java.util.Properties; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * The watch and TV entry points are declared next to {@code codename1.mainName}, + * without the {@code codename1.arg.} prefix. Local builds read them straight off + * the settings file, but the build server only lifts {@code codename1.arg.*} + * keys out of the uploaded file -- so they have to be mirrored into that + * namespace or a cloud build produces no watch app at all. + */ +public class CN1BuildMojoSecondaryEntryPointTest { + + @Test + public void watchMainReachesTheBuildServer() { + Properties props = new Properties(); + props.setProperty("codename1.mainName", "MyApp"); + props.setProperty("codename1.watchMain", "com.mycompany.myapp.MyWatchMain"); + + CN1BuildMojo.mirrorSecondaryEntryPointsToBuildArgs(props); + + assertEquals("com.mycompany.myapp.MyWatchMain", + props.getProperty("codename1.arg.watchMain")); + // The original declaration stays put -- it is a project setting, not a + // build hint, and the local path still reads it from there. + assertEquals("com.mycompany.myapp.MyWatchMain", + props.getProperty("codename1.watchMain")); + } + + @Test + public void watchStandaloneAndTvMainReachTheBuildServer() { + Properties props = new Properties(); + props.setProperty("codename1.watchMain", "com.mycompany.myapp.MyWatchMain"); + props.setProperty("codename1.watchStandalone", "true"); + props.setProperty("codename1.tvMain", "com.mycompany.myapp.MyTvMain"); + + CN1BuildMojo.mirrorSecondaryEntryPointsToBuildArgs(props); + + assertEquals("true", props.getProperty("codename1.arg.watchStandalone")); + assertEquals("com.mycompany.myapp.MyTvMain", props.getProperty("codename1.arg.tvMain")); + } + + @Test + public void surroundingWhitespaceIsTrimmed() { + Properties props = new Properties(); + props.setProperty("codename1.watchMain", " com.mycompany.myapp.MyWatchMain "); + + CN1BuildMojo.mirrorSecondaryEntryPointsToBuildArgs(props); + + assertEquals("com.mycompany.myapp.MyWatchMain", + props.getProperty("codename1.arg.watchMain")); + } + + @Test + public void aProjectWithoutSecondaryEntryPointsIsUntouched() { + Properties props = new Properties(); + props.setProperty("codename1.mainName", "MyApp"); + // A blank declaration is the same as none: it must not switch a build on. + props.setProperty("codename1.watchMain", " "); + + CN1BuildMojo.mirrorSecondaryEntryPointsToBuildArgs(props); + + assertNull(props.getProperty("codename1.arg.watchMain")); + assertNull(props.getProperty("codename1.arg.watchStandalone")); + assertNull(props.getProperty("codename1.arg.tvMain")); + } +} diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/CodenameOneSettings.java b/scripts/settings/common/src/main/java/com/codename1/settings/CodenameOneSettings.java index 4933692c4f5..9989de7d19c 100644 --- a/scripts/settings/common/src/main/java/com/codename1/settings/CodenameOneSettings.java +++ b/scripts/settings/common/src/main/java/com/codename1/settings/CodenameOneSettings.java @@ -489,7 +489,7 @@ private void renderPage() { private void renderBasic() { page.add(pageTitle("Basic", "Core application settings - title, version, package and icon.")); - Container grid = new Container(new GridLayout(3, 2)); + Container grid = new Container(new GridLayout(4, 2)); grid.setUIID(uiid("SettingsFieldGrid")); grid.add(textFieldGroup("Title", "codename1.displayName", false)); grid.add(textFieldGroup("Description", "codename1.description", false)); @@ -497,6 +497,12 @@ private void renderBasic() { grid.add(textFieldGroup("Vendor", "codename1.vendor", false)); grid.add(textFieldGroup("Package Name", "codename1.packageName", false)); grid.add(textFieldGroup("Main Class", "codename1.mainName", false)); + // Secondary entry points. Declaring a watch lifecycle class is the whole + // opt-in for the Apple Watch and Wear OS apps -- there are no wearable + // build hints. Both take a fully-qualified class name, unlike the phone + // main class which is a simple name resolved against the package. + grid.add(textFieldGroup("Watch Main Class", "codename1.watchMain", false)); + grid.add(textFieldGroup("TV Main Class", "codename1.tvMain", false)); page.add(grid); page.add(iconDrop()); page.add(divider()); From dbb6ae51b30f5f892b037b2aeb362234ea9b2a7f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:56:25 +0300 Subject: [PATCH 002/250] Add com.codename1.wearable and make the simulator able to run a paired watch app A watch app and a phone app are two apps on two devices with two sandboxes, and until now Codename One gave them no way to talk. com.codename1.wearable is that channel, and it is the same API on Apple Watch and Wear OS. The API exposes the three transports the platforms actually provide, because choosing the wrong one is the usual reason a watch app "doesn't get the update": sendMessage for a live request/response while both apps are awake, putData for state that must survive sleep and relaunch, and transferFile for bulk. Payloads carry the primitive types both platforms can move natively. Callbacks arrive on the EDT and are queued across a cold start -- the platform starts an app purely to hand it a message, so dropping what arrives before init() finishes would lose exactly the payload that mattered. With nothing on the other end the whole API is inert, so app code needs no platform conditionals. Modelled on com.codename1.car: portable API, spi/WearableBridge from Display, no-op default. The simulator could not do watch development at all: JavaSEPort never overrode isWatch(), so it was always false and the guide's advice to iterate on a watch layout locally was untrue. It now reads watch=true from the skin the same way it reads tablet, prepends "watch" to the platform overrides so the existing theme and CSS layers apply, and ships four generated skins -- Apple Watch 41mm and 45mm, Wear round and Wear square. The round one matters: it is where a layout that assumes a rectangle falls apart, and its safe area is inset accordingly. A Watch menu launches the project's watchMain in a second simulator process, and JavaSEWearableBridge connects the pair so sendMessage and putData genuinely round -trip on the desktop. Two processes rather than two windows in one JVM: Display is a singleton, and sharing it would hide precisely the bugs that appear once the pair is real. Replicated data is files in the shared app home, so a value published while the peer was not running is simply there when it starts; live messages need a loopback socket, so isReachable() is false with no peer open, matching the device instead of papering over it. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/CodenameOneImplementation.java | 12 + CodenameOne/src/com/codename1/ui/Display.java | 12 + .../wearable/WearableConnection.java | 513 ++++++++++++++++++ .../wearable/WearableDataListener.java | 45 ++ .../codename1/wearable/WearableMessage.java | 420 ++++++++++++++ .../wearable/WearableMessageListener.java | 46 ++ .../com/codename1/wearable/WearableNode.java | 84 +++ .../wearable/WearableReplyHandler.java | 44 ++ .../wearable/WearableStateListener.java | 36 ++ .../com/codename1/wearable/package-info.java | 64 +++ .../wearable/spi/WearableBridge.java | 147 +++++ .../codename1/wearable/spi/package-info.java | 29 + .../com/codename1/impl/javase/JavaSEPort.java | 124 +++++ .../impl/javase/JavaSEWearableBridge.java | 483 +++++++++++++++++ tools/watch-skins/GenerateWatchSkins.java | 189 +++++++ 15 files changed, 2248 insertions(+) create mode 100644 CodenameOne/src/com/codename1/wearable/WearableConnection.java create mode 100644 CodenameOne/src/com/codename1/wearable/WearableDataListener.java create mode 100644 CodenameOne/src/com/codename1/wearable/WearableMessage.java create mode 100644 CodenameOne/src/com/codename1/wearable/WearableMessageListener.java create mode 100644 CodenameOne/src/com/codename1/wearable/WearableNode.java create mode 100644 CodenameOne/src/com/codename1/wearable/WearableReplyHandler.java create mode 100644 CodenameOne/src/com/codename1/wearable/WearableStateListener.java create mode 100644 CodenameOne/src/com/codename1/wearable/package-info.java create mode 100644 CodenameOne/src/com/codename1/wearable/spi/WearableBridge.java create mode 100644 CodenameOne/src/com/codename1/wearable/spi/package-info.java create mode 100644 Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java create mode 100644 tools/watch-skins/GenerateWatchSkins.java diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 362b8b74b4e..48e61fa9b34 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -6000,6 +6000,18 @@ public boolean isCarConnected() { return b != null && b.isConnected(); } + /// Returns the platform bridge that carries the `com.codename1.wearable` phone-to-watch API over + /// the native transport (Apple's `WCSession` / Google's Wearable Data Layer), or null when this + /// device has no wearable counterpart (the base implementation). When null, the + /// `com.codename1.wearable` API degrades to a harmless no-op. + /// + /// #### Returns + /// + /// the wearable bridge, or null when unsupported + public com.codename1.wearable.spi.WearableBridge getWearableBridge() { + return null; + } + /// Returns the platform bridge used by the `com.codename1.surfaces` API to render external /// surfaces (home-screen widgets and live activities). Ports supporting surfaces override /// this; the base implementation returns null which renders the whole API an inert no-op. diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index e92ec26b328..3074e19a64f 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -4713,6 +4713,18 @@ public com.codename1.car.spi.CarBridge getCarBridge() { return impl.getCarBridge(); } + /// Returns the platform bridge used by the `com.codename1.wearable` API to talk to the + /// counterpart watch or phone app, or null when this device has no wearable counterpart. + /// Internal -- application code uses the `com.codename1.wearable` API rather than this bridge + /// directly. + /// + /// #### Returns + /// + /// the wearable bridge, or null + public com.codename1.wearable.spi.WearableBridge getWearableBridge() { + return impl.getWearableBridge(); + } + /// Returns the platform bridge used by the `com.codename1.surfaces` API to render external /// surfaces (home-screen widgets and live activities), or null when unsupported on this port. /// Internal -- application code uses the `com.codename1.surfaces` API rather than this bridge diff --git a/CodenameOne/src/com/codename1/wearable/WearableConnection.java b/CodenameOne/src/com/codename1/wearable/WearableConnection.java new file mode 100644 index 00000000000..edb3d5c35be --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableConnection.java @@ -0,0 +1,513 @@ +/* + * 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.wearable; + +import com.codename1.ui.Display; +import com.codename1.wearable.spi.WearableBridge; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The link between a phone app and its watch app. The same API on both ends, and the same API on +/// Apple Watch and Wear OS. +/// +/// ```java +/// // On the phone: publish state the watch should show whenever it next wakes. +/// WearableConnection.putData(new WearableMessage("/steps").put("count", steps)); +/// +/// // On the watch: react to it, and ask for a fresh value on demand. +/// WearableConnection.addDataListener(new WearableDataListener() { +/// public void dataChanged(WearableMessage data) { label.setText("" + data.getInt("count", 0)); } +/// public void dataRemoved(String path) { label.setText("--"); } +/// }); +/// ``` +/// +/// Register listeners from your app's `init()`. A payload that arrives before the first listener is +/// registered -- including the one that made the platform launch your app -- is queued and replayed, +/// but only to a listener that exists by the time the EDT gets to it. +/// +/// When there is nothing on the other end, [#isSupported()] returns false and every call here is an +/// inert no-op, so this API needs no platform conditionals around it. See the package documentation +/// for how to choose between a message, replicated data and a file transfer. +public final class WearableConnection { + private static final List messageListeners = + new ArrayList(); + private static final List dataListeners = + new ArrayList(); + private static final List stateListeners = + new ArrayList(); + + /// Payloads that arrived before anyone was listening. The platform can start an app purely to + /// hand it a message, so dropping these would lose exactly the payload that mattered most. + private static final List pendingDeliveries = new ArrayList(); + + /// Reply handlers for outstanding requests, keyed by the token handed to the bridge. + private static final Map pendingReplies = + new HashMap(); + private static int nextReplyToken = 1; + + private WearableConnection() { + } + + private static WearableBridge bridge() { + return Display.getInstance().getWearableBridge(); + } + + // --- state -------------------------------------------------------------- + + /// Returns true when this device can talk to a counterpart app at all. False on a desktop build, + /// on a phone whose platform has no wearable link, and in the simulator with no watch window + /// open. When this is false every other call here does nothing. + /// + /// #### Returns + /// + /// true if the wearable link is available + public static boolean isSupported() { + WearableBridge b = bridge(); + return b != null && b.isSupported(); + } + + /// Returns true when a counterpart device is paired, whether or not it is switched on or in + /// range. + /// + /// #### Returns + /// + /// true if a counterpart device is paired + public static boolean isPaired() { + WearableBridge b = bridge(); + return b != null && b.isPaired(); + } + + /// Returns true when the peer app can receive a live message right now. This is the condition + /// [#sendMessage(WearableMessage)] needs; [#putData(WearableMessage)] does not. + /// + /// #### Returns + /// + /// true if the peer app is reachable + public static boolean isReachable() { + WearableBridge b = bridge(); + return b != null && b.isReachable(); + } + + /// Returns true when the counterpart app is installed on the paired device. A watch that is + /// paired but has no watch app installed is worth prompting the user about, and is the usual + /// reason a correct-looking `sendMessage` never arrives. + /// + /// #### Returns + /// + /// true if the peer app is installed + public static boolean isCompanionAppInstalled() { + WearableBridge b = bridge(); + return b != null && b.isCompanionAppInstalled(); + } + + /// Returns the counterpart devices currently connected. Apple pairs one watch at a time, so + /// expect at most one; Wear OS allows several. + /// + /// #### Returns + /// + /// the connected nodes, never null + public static List getConnectedNodes() { + List out = new ArrayList(); + WearableBridge b = bridge(); + if (b == null) { + return out; + } + String[] raw = b.getConnectedNodes(); + if (raw == null) { + return out; + } + for (String entry : raw) { + if (entry == null) { + continue; + } + // id \t displayName \t nearby -- see WearableBridge#getConnectedNodes. + String[] parts = com.codename1.util.StringUtil.tokenize(entry, '\t') + .toArray(new String[0]); + if (parts.length == 0) { + continue; + } + String id = parts[0]; + String name = parts.length > 1 ? parts[1] : id; + boolean nearby = parts.length > 2 && "1".equals(parts[2]); + out.add(new WearableNode(id, name, nearby)); + } + return out; + } + + // --- sending ------------------------------------------------------------ + + /// Sends a live message to the peer app, with no reply expected. + /// + /// The message is delivered only if the peer is reachable; if it is not, the message is dropped. + /// Use [#putData(WearableMessage)] when the peer needs to see it eventually rather than now. + /// + /// #### Parameters + /// + /// - `message`: the payload to send + public static void sendMessage(WearableMessage message) { + sendMessage(message, null); + } + + /// Sends a live message to the peer app and waits for its answer. + /// + /// Exactly one method on the handler is called, on the EDT. A reply is not guaranteed: the peer + /// may be asleep, out of range, or running a version of your app that does not know this path. + /// + /// #### Parameters + /// + /// - `message`: the payload to send + /// - `reply`: notified with the answer, or null when no answer is wanted + public static void sendMessage(WearableMessage message, WearableReplyHandler reply) { + if (message == null) { + return; + } + WearableBridge b = bridge(); + if (b == null || !b.isSupported()) { + if (reply != null) { + failReply(reply, "No wearable link on this device"); + } + return; + } + int token = 0; + if (reply != null) { + synchronized (pendingReplies) { + token = nextReplyToken++; + pendingReplies.put(new Integer(token), reply); + } + } + b.sendMessage(message.getPath(), message.toByteArray(), token); + } + + /// Publishes the current value at a path, replacing whatever was there. + /// + /// This is the transport to reach for by default. The value survives both apps being killed and + /// reaches the peer whenever it next runs, so the peer always converges on the latest value. + /// Because each path holds one value, this is state replication and not a message queue -- two + /// rapid updates to the same path may be collapsed into one delivery. + /// + /// #### Parameters + /// + /// - `data`: the payload to publish, addressed to the path to publish under + public static void putData(WearableMessage data) { + if (data == null) { + return; + } + WearableBridge b = bridge(); + if (b != null && b.isSupported()) { + b.putData(data.getPath(), data.toByteArray()); + } + } + + /// Reads the replicated value at a path, as published by either side. + /// + /// #### Parameters + /// + /// - `path`: the path to read + /// + /// #### Returns + /// + /// the value, or null when nothing is published at that path + public static WearableMessage getData(String path) { + WearableBridge b = bridge(); + if (b == null || !b.isSupported() || path == null) { + return null; + } + byte[] raw = b.getData(path); + return raw == null ? null : WearableMessage.fromByteArray(path, raw); + } + + /// Removes the replicated value at a path. The peer is notified through + /// [WearableDataListener#dataRemoved(String)]. + /// + /// #### Parameters + /// + /// - `path`: the path to clear + public static void removeData(String path) { + WearableBridge b = bridge(); + if (b != null && b.isSupported() && path != null) { + b.removeData(path); + } + } + + /// Returns every path that currently holds a replicated value. + /// + /// #### Returns + /// + /// the published paths, never null + public static List getDataPaths() { + List out = new ArrayList(); + WearableBridge b = bridge(); + if (b == null || !b.isSupported()) { + return out; + } + String[] paths = b.getDataPaths(); + if (paths != null) { + for (String p : paths) { + if (p != null) { + out.add(p); + } + } + } + return out; + } + + /// Sends a file to the peer in the background. + /// + /// Delivery is not immediate and may happen after this app has exited -- that is the point. Use + /// it for anything too big for a message: a captured image, a synced document, a map tile. + /// + /// #### Parameters + /// + /// - `path`: the path the peer matches on + /// - `name`: the file name to present to the peer + /// - `contents`: the file bytes + public static void transferFile(String path, String name, byte[] contents) { + WearableBridge b = bridge(); + if (b != null && b.isSupported() && path != null && contents != null) { + b.transferFile(path, name, contents); + } + } + + // --- listeners ---------------------------------------------------------- + + /// Registers a listener for live messages from the peer. Register from your app's `init()`: a + /// message queued while the app was starting is replayed only to listeners that exist by the + /// time the EDT drains the queue. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + public static void addMessageListener(WearableMessageListener l) { + if (l != null && !messageListeners.contains(l)) { + messageListeners.add(l); + drainPending(); + } + } + + /// Removes a previously registered message listener. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + public static void removeMessageListener(WearableMessageListener l) { + messageListeners.remove(l); + } + + /// Registers a listener for replicated data changes. Register from your app's `init()` for the + /// same reason as [#addMessageListener(WearableMessageListener)]. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + public static void addDataListener(WearableDataListener l) { + if (l != null && !dataListeners.contains(l)) { + dataListeners.add(l); + drainPending(); + } + } + + /// Removes a previously registered data listener. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + public static void removeDataListener(WearableDataListener l) { + dataListeners.remove(l); + } + + /// Registers a listener for changes to the link itself -- reachability, pairing, whether the + /// peer app is installed. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + public static void addStateListener(WearableStateListener l) { + if (l != null && !stateListeners.contains(l)) { + stateListeners.add(l); + } + } + + /// Removes a previously registered state listener. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + public static void removeStateListener(WearableStateListener l) { + stateListeners.remove(l); + } + + // --- platform port entry points ----------------------------------------- + + /// Framework/port entry point: hands a message received from the peer to the app. Called by the + /// platform port on whatever thread the native transport uses; delivery is marshalled to the + /// EDT, and queued if no listener has been registered yet. + /// + /// #### Parameters + /// + /// - `path`: the path the message arrived on + /// - `payload`: the encoded payload + /// - `replyToken`: a positive token when the peer is waiting for an answer, otherwise 0 + public static void deliverMessage(final String path, final byte[] payload, final int replyToken) { + deliver(new Runnable() { + public void run() { + WearableMessage m = WearableMessage.fromByteArray(path, payload); + WearableMessage reply = null; + WearableMessageListener[] copy = + messageListeners.toArray(new WearableMessageListener[messageListeners.size()]); + for (WearableMessageListener l : copy) { + WearableMessage r = l.messageReceived(m, replyToken != 0); + if (r != null && reply == null) { + reply = r; + } + } + if (replyToken != 0) { + WearableBridge b = bridge(); + if (b != null) { + b.sendReply(replyToken, + reply == null ? new byte[0] : reply.toByteArray()); + } + } + } + }, !messageListeners.isEmpty()); + } + + /// Framework/port entry point: hands the peer's answer to the waiting reply handler. Called by + /// the platform port; a token with no waiting handler is ignored. + /// + /// #### Parameters + /// + /// - `replyToken`: the token returned with the original request + /// - `payload`: the encoded reply payload, or null when the request failed + /// - `error`: a description of the failure, or null on success + public static void deliverReply(int replyToken, final byte[] payload, final String error) { + final WearableReplyHandler handler; + synchronized (pendingReplies) { + handler = pendingReplies.remove(new Integer(replyToken)); + } + if (handler == null) { + return; + } + Display.getInstance().callSerially(new Runnable() { + public void run() { + if (error != null) { + handler.replyFailed(error); + } else { + handler.replyReceived(WearableMessage.fromByteArray("", payload)); + } + } + }); + } + + /// Framework/port entry point: reports that the peer published or updated a replicated value. + /// Called by the platform port; queued across a cold start like a message. + /// + /// #### Parameters + /// + /// - `path`: the path whose value changed + /// - `payload`: the encoded new value + public static void deliverDataChanged(final String path, final byte[] payload) { + deliver(new Runnable() { + public void run() { + WearableMessage m = WearableMessage.fromByteArray(path, payload); + WearableDataListener[] copy = + dataListeners.toArray(new WearableDataListener[dataListeners.size()]); + for (WearableDataListener l : copy) { + l.dataChanged(m); + } + } + }, !dataListeners.isEmpty()); + } + + /// Framework/port entry point: reports that the peer removed a replicated value. Called by the + /// platform port. + /// + /// #### Parameters + /// + /// - `path`: the path whose value is gone + public static void deliverDataRemoved(final String path) { + deliver(new Runnable() { + public void run() { + WearableDataListener[] copy = + dataListeners.toArray(new WearableDataListener[dataListeners.size()]); + for (WearableDataListener l : copy) { + l.dataRemoved(path); + } + } + }, !dataListeners.isEmpty()); + } + + /// Framework/port entry point: reports that reachability, pairing or peer-app installation + /// changed. Called by the platform port. Unlike payload delivery this is not queued -- state is + /// re-queried by the listener, so a stale notification is worthless. + public static void notifyStateChanged() { + Display.getInstance().callSerially(new Runnable() { + public void run() { + WearableStateListener[] copy = + stateListeners.toArray(new WearableStateListener[stateListeners.size()]); + for (WearableStateListener l : copy) { + l.connectionStateChanged(); + } + } + }); + } + + /// Runs a delivery on the EDT, or parks it until a listener exists. + /// + /// The platform starts an app to hand it a payload, so the payload routinely arrives before the + /// app has finished wiring itself up. Parking rather than dropping is what makes it safe to + /// register listeners in `init()`. + private static void deliver(Runnable delivery, boolean hasListener) { + if (!hasListener) { + synchronized (pendingDeliveries) { + pendingDeliveries.add(delivery); + } + return; + } + Display.getInstance().callSerially(delivery); + } + + private static void drainPending() { + List drained; + synchronized (pendingDeliveries) { + if (pendingDeliveries.isEmpty()) { + return; + } + drained = new ArrayList(pendingDeliveries); + pendingDeliveries.clear(); + } + for (Runnable r : drained) { + Display.getInstance().callSerially(r); + } + } + + private static void failReply(final WearableReplyHandler reply, final String message) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + reply.replyFailed(message); + } + }); + } +} diff --git a/CodenameOne/src/com/codename1/wearable/WearableDataListener.java b/CodenameOne/src/com/codename1/wearable/WearableDataListener.java new file mode 100644 index 00000000000..08f98733d69 --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableDataListener.java @@ -0,0 +1,45 @@ +/* + * 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.wearable; + +/// Notified when replicated data changes on the peer. +/// +/// Callbacks arrive on the EDT, and changes that landed while your app was not running are replayed +/// to the first listener you register -- that is the point of replicated data, so register from your +/// app's `init()`. +public interface WearableDataListener { + + /// Called when the peer publishes or updates the value at a path. + /// + /// #### Parameters + /// + /// - `data`: the new value, addressed to the path the peer published it under + void dataChanged(WearableMessage data); + + /// Called when the peer removes the value at a path. + /// + /// #### Parameters + /// + /// - `path`: the path whose value is gone + void dataRemoved(String path); +} diff --git a/CodenameOne/src/com/codename1/wearable/WearableMessage.java b/CodenameOne/src/com/codename1/wearable/WearableMessage.java new file mode 100644 index 00000000000..15c98ce9230 --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableMessage.java @@ -0,0 +1,420 @@ +/* + * 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.wearable; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// A payload addressed to a path, used both for live messages and for replicated data. +/// +/// The path is what the receiving side matches on -- `"/steps"`, `"/workout/start"` -- and works +/// like a URL path, so give related payloads a common prefix. Values are the primitive types every +/// wearable transport can carry natively on both platforms: string, int, long, double, boolean and +/// raw bytes. +/// +/// ```java +/// WearableMessage m = new WearableMessage("/steps") +/// .put("count", 8412) +/// .put("goalReached", true); +/// WearableConnection.putData(m); +/// ``` +/// +/// Reads name a default, so a peer running an older version of your app that never sent a key gets +/// a sane value rather than an exception. That matters more than usual here: the two apps are +/// updated independently and can be different versions of each other for a long time. +public class WearableMessage { + /// Wire format version, so a newer peer can recognize a payload it cannot parse instead of + /// misreading it. + private static final int FORMAT_VERSION = 1; + + private static final int TYPE_STRING = 1; + private static final int TYPE_INT = 2; + private static final int TYPE_LONG = 3; + private static final int TYPE_DOUBLE = 4; + private static final int TYPE_BOOLEAN = 5; + private static final int TYPE_BYTES = 6; + + private final String path; + private final Map values = new LinkedHashMap(); + + /// Creates an empty message addressed to a path. + /// + /// #### Parameters + /// + /// - `path`: the path the peer matches on, conventionally starting with `/` + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if the path is null or empty + public WearableMessage(String path) { + if (path == null || path.length() == 0) { + throw new IllegalArgumentException("A wearable message needs a path"); + } + this.path = path; + } + + /// Returns the path this message is addressed to. + /// + /// #### Returns + /// + /// the path + public String getPath() { + return path; + } + + /// Returns the keys carried by this message, in insertion order. + /// + /// #### Returns + /// + /// the keys present in the payload + public List getKeys() { + return new ArrayList(values.keySet()); + } + + /// Returns true if the payload carries a value under the supplied key. + /// + /// #### Parameters + /// + /// - `key`: the key to look for + /// + /// #### Returns + /// + /// true if the key is present + public boolean contains(String key) { + return values.containsKey(key); + } + + /// Adds a string value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `value`: the value; a null value removes the key + /// + /// #### Returns + /// + /// this message, for chaining + public WearableMessage put(String key, String value) { + return set(key, value); + } + + /// Adds an int value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `value`: the value + /// + /// #### Returns + /// + /// this message, for chaining + public WearableMessage put(String key, int value) { + return set(key, new Integer(value)); + } + + /// Adds a long value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `value`: the value + /// + /// #### Returns + /// + /// this message, for chaining + public WearableMessage put(String key, long value) { + return set(key, new Long(value)); + } + + /// Adds a double value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `value`: the value + /// + /// #### Returns + /// + /// this message, for chaining + public WearableMessage put(String key, double value) { + return set(key, new Double(value)); + } + + /// Adds a boolean value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `value`: the value + /// + /// #### Returns + /// + /// this message, for chaining + public WearableMessage put(String key, boolean value) { + return set(key, Boolean.valueOf(value)); + } + + /// Adds a raw byte payload. Keep it small: a message is delivered over a low-bandwidth link and + /// the platforms reject oversized payloads outright. Use + /// [WearableConnection#transferFile(String,String,byte[])] for anything substantial. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `value`: the bytes; a null value removes the key + /// + /// #### Returns + /// + /// this message, for chaining + public WearableMessage put(String key, byte[] value) { + return set(key, value); + } + + private WearableMessage set(String key, Object value) { + if (key == null || key.length() == 0) { + throw new IllegalArgumentException("A wearable message value needs a key"); + } + if (value == null) { + values.remove(key); + } else { + values.put(key, value); + } + return this; + } + + /// Reads a string value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `defaultValue`: returned when the key is absent or holds another type + /// + /// #### Returns + /// + /// the value, or the default + public String getString(String key, String defaultValue) { + Object o = values.get(key); + return o instanceof String ? (String) o : defaultValue; + } + + /// Reads an int value. Accepts any numeric value, so a peer that sent a long or a double still + /// reads back sensibly. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `defaultValue`: returned when the key is absent or holds a non-numeric type + /// + /// #### Returns + /// + /// the value, or the default + public int getInt(String key, int defaultValue) { + Object o = values.get(key); + return o instanceof Number ? ((Number) o).intValue() : defaultValue; + } + + /// Reads a long value. Accepts any numeric value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `defaultValue`: returned when the key is absent or holds a non-numeric type + /// + /// #### Returns + /// + /// the value, or the default + public long getLong(String key, long defaultValue) { + Object o = values.get(key); + return o instanceof Number ? ((Number) o).longValue() : defaultValue; + } + + /// Reads a double value. Accepts any numeric value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `defaultValue`: returned when the key is absent or holds a non-numeric type + /// + /// #### Returns + /// + /// the value, or the default + public double getDouble(String key, double defaultValue) { + Object o = values.get(key); + return o instanceof Number ? ((Number) o).doubleValue() : defaultValue; + } + + /// Reads a boolean value. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `defaultValue`: returned when the key is absent or holds another type + /// + /// #### Returns + /// + /// the value, or the default + public boolean getBoolean(String key, boolean defaultValue) { + Object o = values.get(key); + return o instanceof Boolean ? ((Boolean) o).booleanValue() : defaultValue; + } + + /// Reads a raw byte payload. + /// + /// #### Parameters + /// + /// - `key`: the key + /// - `defaultValue`: returned when the key is absent or holds another type + /// + /// #### Returns + /// + /// the value, or the default + public byte[] getBytes(String key, byte[] defaultValue) { + Object o = values.get(key); + return o instanceof byte[] ? (byte[]) o : defaultValue; + } + + // --- wire format -------------------------------------------------------- + + /// Serializes the payload to the compact form the platform bridges carry. Application code does + /// not normally call this; [WearableConnection] does it on the way out. + /// + /// #### Returns + /// + /// the encoded payload, never null + public byte[] toByteArray() { + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bo); + try { + out.writeByte(FORMAT_VERSION); + out.writeShort(values.size()); + for (Map.Entry e : values.entrySet()) { + out.writeUTF(e.getKey()); + Object v = e.getValue(); + if (v instanceof String) { + out.writeByte(TYPE_STRING); + out.writeUTF((String) v); + } else if (v instanceof Integer) { + out.writeByte(TYPE_INT); + out.writeInt(((Integer) v).intValue()); + } else if (v instanceof Long) { + out.writeByte(TYPE_LONG); + out.writeLong(((Long) v).longValue()); + } else if (v instanceof Double) { + out.writeByte(TYPE_DOUBLE); + out.writeDouble(((Double) v).doubleValue()); + } else if (v instanceof Boolean) { + out.writeByte(TYPE_BOOLEAN); + out.writeBoolean(((Boolean) v).booleanValue()); + } else { + byte[] b = (byte[]) v; + out.writeByte(TYPE_BYTES); + out.writeInt(b.length); + out.write(b); + } + } + out.flush(); + } catch (IOException err) { + // A ByteArrayOutputStream cannot fail; rethrowing keeps callers honest + // if that ever stops being true. + throw new IllegalStateException("Failed to encode wearable payload: " + err); + } + return bo.toByteArray(); + } + + /// Reconstructs a payload received from the peer. Application code does not normally call this; + /// [WearableConnection] does it on the way in. + /// + /// #### Parameters + /// + /// - `path`: the path the payload arrived on + /// - `data`: the encoded payload, may be null or empty for a payload with no values + /// + /// #### Returns + /// + /// the decoded message, never null; a payload this build cannot parse decodes to an empty + /// message on the same path rather than throwing + public static WearableMessage fromByteArray(String path, byte[] data) { + WearableMessage m = new WearableMessage(path); + if (data == null || data.length == 0) { + return m; + } + DataInputStream in = new DataInputStream(new ByteArrayInputStream(data)); + try { + int version = in.readByte(); + if (version != FORMAT_VERSION) { + // A peer running a future version of the app. Reading on would + // produce garbage values, which is worse than no values at all. + com.codename1.io.Log.p("Wearable: ignoring a payload on " + path + + " in wire format " + version + "; this build understands " + + FORMAT_VERSION); + return m; + } + int count = in.readShort(); + for (int i = 0; i < count; i++) { + String key = in.readUTF(); + int type = in.readByte(); + switch (type) { + case TYPE_STRING: + m.put(key, in.readUTF()); + break; + case TYPE_INT: + m.put(key, in.readInt()); + break; + case TYPE_LONG: + m.put(key, in.readLong()); + break; + case TYPE_DOUBLE: + m.put(key, in.readDouble()); + break; + case TYPE_BOOLEAN: + m.put(key, in.readBoolean()); + break; + case TYPE_BYTES: + byte[] b = new byte[in.readInt()]; + in.readFully(b); + m.put(key, b); + break; + default: + com.codename1.io.Log.p("Wearable: unknown value type " + type + + " on " + path + "; the rest of the payload is unreadable"); + return m; + } + } + } catch (IOException err) { + com.codename1.io.Log.p("Wearable: truncated payload on " + path + ": " + err); + } + return m; + } + + @Override + public String toString() { + return "WearableMessage[" + path + " " + values.keySet() + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/wearable/WearableMessageListener.java b/CodenameOne/src/com/codename1/wearable/WearableMessageListener.java new file mode 100644 index 00000000000..9c73bbe7ed6 --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableMessageListener.java @@ -0,0 +1,46 @@ +/* + * 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.wearable; + +/// Notified when the peer app sends a live message. +/// +/// Callbacks arrive on the EDT. A message that arrived while your app was starting -- including the +/// one that caused the platform to launch it -- is replayed to the first listener you register, so +/// register from your app's `init()` rather than from a form. +public interface WearableMessageListener { + + /// Called when a message arrives from the peer app. + /// + /// If the sender asked for a reply, answer it by returning a message; returning null sends an + /// empty reply. The sender is blocked waiting, so answer quickly and do slow work afterwards. + /// + /// #### Parameters + /// + /// - `message`: the received payload, addressed to the path the sender chose + /// - `expectsReply`: true when the sender is waiting for an answer + /// + /// #### Returns + /// + /// the reply to send back, or null for none + WearableMessage messageReceived(WearableMessage message, boolean expectsReply); +} diff --git a/CodenameOne/src/com/codename1/wearable/WearableNode.java b/CodenameOne/src/com/codename1/wearable/WearableNode.java new file mode 100644 index 00000000000..79efce3d1c8 --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableNode.java @@ -0,0 +1,84 @@ +/* + * 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.wearable; + +/// A device on the other end of the link: the watch as seen from the phone, or the phone as seen +/// from the watch. +/// +/// Apple pairs a phone with exactly one watch at a time, so there is at most one node there. Wear OS +/// allows several watches paired to one phone, so a phone app can see more than one -- send to all +/// of them unless you have a reason to pick. +public class WearableNode { + private final String id; + private final String displayName; + private final boolean nearby; + + /// Creates a node description. Called by the platform ports; application code obtains nodes from + /// [WearableConnection#getConnectedNodes()]. + /// + /// #### Parameters + /// + /// - `id`: the platform's opaque identifier for the device + /// - `displayName`: the device name a person would recognize + /// - `nearby`: true when the device is directly connected rather than reachable over the cloud + public WearableNode(String id, String displayName, boolean nearby) { + this.id = id; + this.displayName = displayName; + this.nearby = nearby; + } + + /// Returns the platform's opaque identifier for this device, stable for as long as the pairing + /// lasts. + /// + /// #### Returns + /// + /// the node id + public String getId() { + return id; + } + + /// Returns the device name a person would recognize, suitable for showing in a UI. + /// + /// #### Returns + /// + /// the display name + public String getDisplayName() { + return displayName; + } + + /// Returns true when the device is directly connected (Bluetooth or the same network) rather + /// than merely reachable through the cloud. Only a nearby node can receive a live message; + /// replicated data reaches both. + /// + /// #### Returns + /// + /// true if the node is directly connected + public boolean isNearby() { + return nearby; + } + + @Override + public String toString() { + return "WearableNode[" + displayName + (nearby ? ", nearby]" : "]"); + } +} diff --git a/CodenameOne/src/com/codename1/wearable/WearableReplyHandler.java b/CodenameOne/src/com/codename1/wearable/WearableReplyHandler.java new file mode 100644 index 00000000000..ddf44cfb013 --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableReplyHandler.java @@ -0,0 +1,44 @@ +/* + * 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.wearable; + +/// Receives the answer to a message that asked for one. +/// +/// Exactly one of the two methods is called, on the EDT. A reply is not guaranteed: the peer may be +/// asleep, out of range, or running a version of your app that does not know the path you sent. +public interface WearableReplyHandler { + + /// Called with the peer's answer. + /// + /// #### Parameters + /// + /// - `reply`: the peer's response, on the same path as the request + void replyReceived(WearableMessage reply); + + /// Called when no answer could be obtained. + /// + /// #### Parameters + /// + /// - `message`: a description of what went wrong, suitable for a log rather than a UI + void replyFailed(String message); +} diff --git a/CodenameOne/src/com/codename1/wearable/WearableStateListener.java b/CodenameOne/src/com/codename1/wearable/WearableStateListener.java new file mode 100644 index 00000000000..770fccb95ed --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/WearableStateListener.java @@ -0,0 +1,36 @@ +/* + * 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.wearable; + +/// Notified when the link to the peer app changes. +/// +/// Use it to enable or disable the parts of your UI that need a live peer -- a "send to watch" +/// button, say -- rather than polling [WearableConnection#isReachable()]. Callbacks arrive on the +/// EDT. +public interface WearableStateListener { + + /// Called when reachability, pairing or peer-app installation changes. Query + /// [WearableConnection#isReachable()], [WearableConnection#isPaired()] and + /// [WearableConnection#isCompanionAppInstalled()] for the new state. + void connectionStateChanged(); +} diff --git a/CodenameOne/src/com/codename1/wearable/package-info.java b/CodenameOne/src/com/codename1/wearable/package-info.java new file mode 100644 index 00000000000..738103cac9c --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/package-info.java @@ -0,0 +1,64 @@ +/* + * 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. + */ + +/// Talking between a phone app and its watch app. +/// +/// A watch app and a phone app are two apps on two devices with two sandboxes. Nothing is shared +/// between them automatically: `Storage`, `Preferences` and the SQLite database are per-device, and +/// there is no cross-device container. This package is the channel between them, and it is the same +/// channel on Apple Watch (`WCSession`) and Wear OS (the Wearable Data Layer). +/// +/// #### Three ways to move information, and how to choose +/// +/// The platforms offer three transports because they answer three different questions. Picking the +/// wrong one is the usual source of "my watch app didn't get the update": +/// +/// | You need | Use | Delivered | +/// |---|---|---| +/// | An answer, now, while both apps are awake | [WearableConnection#sendMessage(WearableMessage,WearableReplyHandler)] | Immediately, or it fails | +/// | The peer to end up with the latest state, whenever it next looks | [WearableConnection#putData(WearableMessage)] | Eventually, survives sleep and relaunch | +/// | To move a file or a large blob | [WearableConnection#transferFile(String,String,byte[])] | In the background, possibly much later | +/// +/// A message is a phone call: it only works if someone picks up ([WearableConnection#isReachable()] +/// is true). Data is a shared noticeboard: you pin the current value at a path and the peer reads it +/// whenever it wakes, so it is what you want for "the watch should show my latest step count". Data +/// replaces the value at a path rather than queueing, so do not use it as a message queue. +/// +/// #### The dead-process rule +/// +/// The peer app may not be running when something arrives for it. The platform starts it, which +/// means your listener may not be registered yet. Callbacks that arrive before you register are +/// therefore queued and replayed to your first listener, on the EDT. Register listeners from your +/// `init()` rather than from a form, or you will race the platform and lose the callback that +/// launched you. +/// +/// #### Degrades instead of failing +/// +/// On a device with no counterpart -- a phone with no paired watch, a desktop build, the +/// simulator with no watch window open -- there is no bridge, [WearableConnection#isSupported()] +/// returns false and every call is an inert no-op. Application code needs no platform conditionals. +/// +/// Merely referencing this package makes the build wire the native plumbing (`WatchConnectivity` on +/// Apple, the `play-services-wearable` dependency and a `WearableListenerService` on Android); apps +/// that never use it pay nothing. See the "Wearables" chapter of the developer guide. +package com.codename1.wearable; diff --git a/CodenameOne/src/com/codename1/wearable/spi/WearableBridge.java b/CodenameOne/src/com/codename1/wearable/spi/WearableBridge.java new file mode 100644 index 00000000000..073b2c80e83 --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/spi/WearableBridge.java @@ -0,0 +1,147 @@ +/* + * 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.wearable.spi; + +/// Internal service-provider interface implemented by each platform port to carry the +/// `com.codename1.wearable` API onto the native phone-to-watch transport (Apple's `WCSession` or +/// Google's Wearable Data Layer). +/// +/// Application code never touches this interface -- it is obtained by the `com.codename1.wearable` +/// framework from `com.codename1.ui.Display#getWearableBridge()` and driven through the public +/// `com.codename1.wearable.WearableConnection` API. The base implementation returns `null`, which is +/// why the public API degrades to a harmless no-op on the simulator and on ports with no paired +/// device (so application code needs no platform `if` statements). +/// +/// Payloads cross this interface as the opaque bytes produced by +/// `com.codename1.wearable.WearableMessage#toByteArray()`, so a port only has to move bytes and +/// never has to understand the value model. Incoming traffic is pushed back the other way by calling +/// the static entry points on `com.codename1.wearable.WearableConnection` +/// (`deliverMessage`, `deliverReply`, `deliverDataChanged`, `deliverDataRemoved`, +/// `notifyStateChanged`), which take care of EDT dispatch and of queueing across a cold start. +public interface WearableBridge { + + /// Returns true when this device can talk to a counterpart at all -- the transport exists and + /// the app is allowed to use it. False on a platform with no wearable link, which makes the + /// whole public API inert. + /// + /// #### Returns + /// + /// true if the wearable transport is available + boolean isSupported(); + + /// Returns true when a counterpart device is paired with this one, whether or not it is + /// currently switched on or in range. + /// + /// #### Returns + /// + /// true if a counterpart device is paired + boolean isPaired(); + + /// Returns true when the peer app can receive a live message right now. This is the condition + /// `sendMessage` needs; replicated data does not. + /// + /// #### Returns + /// + /// true if the peer app is reachable + boolean isReachable(); + + /// Returns true when the counterpart app is actually installed on the paired device. A paired + /// watch with no watch app installed is the common case worth telling the user about. + /// + /// #### Returns + /// + /// true if the peer app is installed + boolean isCompanionAppInstalled(); + + /// Returns the currently connected counterpart devices, one entry per device, each formatted as + /// `id \t displayName \t 1|0` where the trailing flag is whether the device is nearby. The flat + /// string form keeps the interface to primitives so native ports do not have to construct Java + /// objects. + /// + /// #### Returns + /// + /// the connected nodes, never null; an empty array when nothing is connected + String[] getConnectedNodes(); + + /// Sends a live message to the peer app, delivered only if it is reachable. + /// + /// #### Parameters + /// + /// - `path`: the path the peer matches on + /// - `payload`: the encoded payload + /// - `replyToken`: a positive token to answer with `WearableConnection.deliverReply` when the + /// sender wants a reply, or 0 when it does not + void sendMessage(String path, byte[] payload, int replyToken); + + /// Answers a message the peer sent with a reply token. + /// + /// #### Parameters + /// + /// - `replyToken`: the token that arrived with the request + /// - `payload`: the encoded reply payload + void sendReply(int replyToken, byte[] payload); + + /// Publishes or replaces the replicated value at a path. The value must survive this app being + /// killed and must reach the peer whenever it next runs. + /// + /// #### Parameters + /// + /// - `path`: the path to publish under + /// - `payload`: the encoded payload + void putData(String path, byte[] payload); + + /// Returns the replicated value at a path, as published by either side. + /// + /// #### Parameters + /// + /// - `path`: the path to read + /// + /// #### Returns + /// + /// the encoded payload, or null when nothing is published at that path + byte[] getData(String path); + + /// Removes the replicated value at a path. + /// + /// #### Parameters + /// + /// - `path`: the path to clear + void removeData(String path); + + /// Returns every path that currently holds a replicated value. + /// + /// #### Returns + /// + /// the published paths, never null + String[] getDataPaths(); + + /// Transfers a file to the peer in the background. Delivery may happen long after this returns, + /// including after this app has exited. + /// + /// #### Parameters + /// + /// - `path`: the path the peer matches on + /// - `name`: the file name to present to the peer + /// - `contents`: the file bytes + void transferFile(String path, String name, byte[] contents); +} diff --git a/CodenameOne/src/com/codename1/wearable/spi/package-info.java b/CodenameOne/src/com/codename1/wearable/spi/package-info.java new file mode 100644 index 00000000000..99ccf01494a --- /dev/null +++ b/CodenameOne/src/com/codename1/wearable/spi/package-info.java @@ -0,0 +1,29 @@ +/* + * 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. + */ + +/// Internal service-provider interface for the `com.codename1.wearable` phone-to-watch API. The +/// single `WearableBridge` interface is implemented by each platform port to carry payloads over the +/// native transport (Apple's `WCSession` / Google's Wearable Data Layer). Application code does not +/// use this package directly -- it drives the public `com.codename1.wearable` API, which obtains the +/// bridge from the platform implementation. +package com.codename1.wearable.spi; diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 357cf751e1d..893f0fd99e3 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -386,6 +386,37 @@ void disconnectSimulatedCar() { } } + /// Returns the JavaSE phone-to-watch bridge, created lazily on first use. + /// + /// The bridge is live only when the project actually declares a watch app + /// (`codename1.watchMain`); without one there is nothing to pair with, so the whole + /// `com.codename1.wearable` API stays inert exactly as it would on a phone with no watch. + @Override + public com.codename1.wearable.spi.WearableBridge getWearableBridge() { + if (wearableBridge == null) { + File home = new File(System.getProperty("user.home") + File.separator + getAppHomeDir()); + wearableBridge = new JavaSEWearableBridge(home, isWatchCompanionProcess(), + getWatchMainClass() != null); + } + return wearableBridge; + } + + /// Returns the project's declared watch lifecycle class, or null when it declares none. Read + /// from the same `codename1.watchMain` setting the device builds use, which the simulator + /// launcher exposes as a system property. + static String getWatchMainClass() { + String s = System.getProperty("codename1.watchMain"); + if (s == null || s.trim().length() == 0) { + return null; + } + return s.trim(); + } + + /// True when this JVM is the watch half of a simulated pair rather than the phone half. + static boolean isWatchCompanionProcess() { + return "watch".equals(System.getProperty("cn1.wearable.side")); + } + /// Returns the JavaSE external-surfaces bridge, created lazily on first use. In simulator mode /// published widget timelines render in the Widgets preview window (Widgets menu); in desktop /// mode they render in frameless always-on-top floating windows that persist across runs. @@ -676,6 +707,11 @@ public static void setInvokePointerHover(boolean aInvokePointerHover) { private static File baseResourceDir; private static final String DEFAULT_SKIN = "/iPhoneX.skin"; + /// Skin the watch half of a simulated pair comes up on. The other shipped watch skins + /// (AppleWatch41mm, WearRound, WearSquare) are selectable from the skin menu once it is running; + /// WearRound in particular is worth checking a layout against, because a round face is where a + /// design that assumes a rectangle falls apart. + private static final String WATCH_COMPANION_SKIN = "/AppleWatch45mm.skin"; private static final String DEFAULT_SKINS = DEFAULT_SKIN+";"; private static String appHomeDir = ".cn1"; @@ -892,6 +928,10 @@ public static void setShowEDTViolationStacks(boolean aShowEDTViolationStacks) { private static String currentSimulatorNativeTheme; private static int softkeyCount = 1; private static boolean tablet; + /// True when the loaded skin declares `watch=true`, which is how an Apple Watch or Wear OS skin + /// identifies itself. Drives `isWatch()` and the `"watch"` resource/CSS override layer, so a + /// watch layout can be developed here rather than only on a device. + private static boolean watch; private static String DEFAULT_FONT = "Arial-plain-11"; private static EventDispatcher formChangeListener; private static boolean autoAdjustFontSize = true; @@ -967,6 +1007,10 @@ private static boolean computeUseAppFrame() { // simulator mode and the desktop floating widget windows in desktop mode. Created lazily so // apps that never touch the surfaces API pay nothing. private JavaSEWidgetBridge surfaceBridge; + // Phone-to-watch link (com.codename1.wearable). Both halves of a paired pair run their own + // simulator process and meet through the shared app home; created lazily so apps that never + // touch the wearable API pay nothing. + private JavaSEWearableBridge wearableBridge; // Desktop floating widget windows manager, created beside the bridge in desktop mode only. private JavaSEWidgetWindows widgetWindows; // Application frame used for simulator @@ -4700,6 +4744,7 @@ private void loadSkinFile(InputStream skin, final JFrame frm) { Integer.parseInt(props.getProperty("smallFontSize", "" + sm)), Integer.parseInt(props.getProperty("largeFontSize", "" + la))); tablet = props.getProperty("tablet", "false").equalsIgnoreCase("true"); + watch = props.getProperty("watch", "false").equalsIgnoreCase("true"); rotateTouchKeysOnLandscape = props.getProperty("rotateKeys", "false").equalsIgnoreCase("true"); touchDevice = props.getProperty("touch", "true").equalsIgnoreCase("true"); keyboardType = Integer.parseInt(props.getProperty("keyboardType", "0")); @@ -5581,6 +5626,64 @@ public void actionPerformed(ActionEvent e) { return carMenu; } + /// Builds the simulator "Watch" menu, which launches the project's watch app beside the phone + /// app so the pair can be developed together. + /// + /// The watch app runs in its own JVM rather than in another window of this one. A watch app and + /// a phone app are two apps in two sandboxes on a device; sharing a `Display` here would let + /// bugs through that only appear once the pair is real. The two processes find each other + /// through the shared app home (see {@link JavaSEWearableBridge}), so `sendMessage` and + /// `putData` genuinely round-trip on the desktop. + private JMenu buildWatchMenu() { + JMenu watchMenu = new JMenu("Watch"); + registerMenuWithBlit(watchMenu); + JMenuItem launch = new JMenuItem("Launch Watch App"); + launch.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + launchWatchCompanion(); + } + }); + watchMenu.add(launch); + return watchMenu; + } + + /// Starts the watch app in a second simulator process, on a watch skin, wired to this one. + void launchWatchCompanion() { + String watchMain = getWatchMainClass(); + if (watchMain == null) { + javax.swing.JOptionPane.showMessageDialog(window, + "This project declares no watch app.\n\n" + + "Add codename1.watchMain= to\n" + + "codenameone_settings.properties and run again. That one setting builds the\n" + + "watch app on both Apple Watch and Wear OS.", + "Watch App", javax.swing.JOptionPane.INFORMATION_MESSAGE); + return; + } + try { + List cmd = new ArrayList(); + cmd.add(new File(new File(System.getProperty("java.home"), "bin"), "java").getAbsolutePath()); + cmd.add("-cp"); + cmd.add(System.getProperty("java.class.path")); + // The watch half needs to know which side it is, which class to start, and to come up on + // a watch skin so CN.isWatch() is true and the "watch" override layer applies. + cmd.add("-Dcn1.wearable.side=watch"); + cmd.add("-Dcodename1.watchMain=" + watchMain); + cmd.add("-Dskin=" + WATCH_COMPANION_SKIN); + cmd.add("-Ddskin=" + WATCH_COMPANION_SKIN); + if (System.getProperty("cn1.class.path") != null) { + cmd.add("-Dcn1.class.path=" + System.getProperty("cn1.class.path")); + } + cmd.add(Simulator.class.getName()); + cmd.add(watchMain); + new ProcessBuilder(cmd).inheritIO().start(); + } catch (Exception err) { + javax.swing.JOptionPane.showMessageDialog(window, + "Could not launch the watch app:\n" + err, + "Watch App", javax.swing.JOptionPane.ERROR_MESSAGE); + } + } + /// Builds the simulator "Widgets" menu, which opens the Widgets preview window rendering the /// app's published `com.codename1.surfaces` timelines and live activities locally -- kind list, /// size selector, light/dark toggle, timeline auto-advance and a mock Dynamic Island. @@ -7097,6 +7200,10 @@ public void actionPerformed(ActionEvent e) { bar.add(extensionMenu); } bar.add(buildCarMenu()); + // Only offered on the phone half of a pair: the watch app has nothing to launch. + if (!isWatchCompanionProcess()) { + bar.add(buildWatchMenu()); + } bar.add(buildWidgetsMenu()); bar.add(MCPDesktopMenu.build("Codename One Simulator", window)); bar.add(helpMenu); @@ -13997,6 +14104,13 @@ public boolean isTablet() { return tablet || isDesktop(); } + /// A watch skin makes the simulator report the watch form factor, so `CN.isWatch()` branches and + /// the `"watch"` theme/CSS override layer can be exercised on the desktop instead of only on a + /// device. + public boolean isWatch() { + return watch; + } + public boolean isDesktop() { return portraitSkin == null; } @@ -14888,6 +15002,16 @@ public Simd createSimd() { * @inheritDoc */ public String[] getPlatformOverrides() { + if(isWatch()) { + // "watch" leads, matching the iOS and Android ports, so a resource or + // CSS override written for a device also applies here. The skin's own + // overrideNames follow, which is where "applewatch" / "android-watch" + // come from. + String[] out = new String[platformOverrides.length + 1]; + out[0] = "watch"; + System.arraycopy(platformOverrides, 0, out, 1, platformOverrides.length); + return out; + } if(isDesktop()) { return new String[] {"desktop", "tablet"}; } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java new file mode 100644 index 00000000000..fb7ad52905b --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java @@ -0,0 +1,483 @@ +/* + * 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.javase; + +import com.codename1.wearable.WearableConnection; +import com.codename1.wearable.spi.WearableBridge; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The desktop stand-in for `WCSession` / the Wearable Data Layer, so the phone-to-watch API can be +/// developed and debugged without a device. +/// +/// The phone app and the watch app run as two separate JVMs -- they are two apps with two sandboxes +/// on a device, and pretending otherwise in the simulator would let bugs through. Each side creates +/// one of these, and the two halves find each other through a directory both resolve to (the app +/// home, which is per-project and therefore shared by the pair): +/// +/// - **Replicated data** is files under `wearable/data`. Both sides read and write the same +/// directory, so a value published while the peer was not running is simply there when it starts, +/// which is exactly the guarantee the real transports make. A poller notices the peer's writes. +/// - **Live messages** need a live peer, so they go over a loopback socket on a port derived from +/// that same directory. Whichever side starts first binds it and the other connects; if nobody is +/// on the other end, [#isReachable()] is false and messages are dropped -- again matching the +/// device behavior rather than papering over it. +/// - **File transfers** are modelled as data writes carrying the bytes, since the desktop has no +/// background-transfer scheduler worth simulating. +class JavaSEWearableBridge implements WearableBridge { + /// Frame kinds on the loopback socket. + private static final int FRAME_MESSAGE = 1; + private static final int FRAME_REPLY = 2; + private static final int FRAME_HELLO = 3; + + private final File dataDir; + private final File portFile; + private final boolean watchSide; + /// True when the project declares a watch app at all. Without one there is nothing to pair with, + /// which is what a phone with no watch looks like. + private final boolean paired; + + private volatile Socket peer; + private volatile DataOutputStream peerOut; + private volatile boolean closed; + + /// Last-seen modification time per data file, so the poller reports only genuine changes. + private final Map seenData = new HashMap(); + + /// Creates the bridge and starts the rendezvous and data-watching threads. + /// + /// @param home the per-project app home directory both sides resolve to + /// @param watchSide true when this JVM is running the watch app + /// @param paired true when the project declares a watch app + JavaSEWearableBridge(File home, boolean watchSide, boolean paired) { + this.watchSide = watchSide; + this.paired = paired; + File root = new File(home, "wearable"); + this.dataDir = new File(root, "data"); + this.portFile = new File(root, "port"); + dataDir.mkdirs(); + primeSeenData(); + if (paired) { + startRendezvous(); + startDataWatcher(); + } + } + + // --- state -------------------------------------------------------------- + + public boolean isSupported() { + return paired; + } + + public boolean isPaired() { + return paired; + } + + public boolean isReachable() { + return peerOut != null; + } + + public boolean isCompanionAppInstalled() { + return paired; + } + + public String[] getConnectedNodes() { + if (!isReachable()) { + return new String[0]; + } + // Mirrors the id \t displayName \t nearby form the device ports produce. + String name = watchSide ? "Simulated Phone" : "Simulated Watch"; + return new String[] {(watchSide ? "phone" : "watch") + "\t" + name + "\t1"}; + } + + // --- messages ----------------------------------------------------------- + + public void sendMessage(String path, byte[] payload, int replyToken) { + DataOutputStream out = peerOut; + if (out == null) { + if (replyToken != 0) { + WearableConnection.deliverReply(replyToken, null, + "The " + (watchSide ? "phone" : "watch") + " app is not running"); + } + return; + } + try { + writeFrame(out, FRAME_MESSAGE, path, payload, replyToken); + } catch (IOException err) { + dropPeer(); + if (replyToken != 0) { + WearableConnection.deliverReply(replyToken, null, "Link lost: " + err); + } + } + } + + public void sendReply(int replyToken, byte[] payload) { + DataOutputStream out = peerOut; + if (out == null) { + return; + } + try { + writeFrame(out, FRAME_REPLY, "", payload, replyToken); + } catch (IOException err) { + dropPeer(); + } + } + + // --- replicated data ---------------------------------------------------- + + public void putData(String path, byte[] payload) { + File f = dataFile(path); + try { + f.getParentFile().mkdirs(); + FileOutputStream out = new FileOutputStream(f); + try { + out.write(payload); + } finally { + out.close(); + } + // Our own write must not come back to us as a peer change. + synchronized (seenData) { + seenData.put(f.getName(), new Long(f.lastModified())); + } + } catch (IOException err) { + com.codename1.io.Log.p("Wearable simulator: failed to publish " + path + ": " + err); + } + } + + public byte[] getData(String path) { + File f = dataFile(path); + if (!f.exists()) { + return null; + } + try { + return readFully(f); + } catch (IOException err) { + return null; + } + } + + public void removeData(String path) { + File f = dataFile(path); + if (f.delete()) { + synchronized (seenData) { + seenData.remove(f.getName()); + } + } + } + + public String[] getDataPaths() { + File[] files = dataDir.listFiles(); + if (files == null) { + return new String[0]; + } + List out = new ArrayList(); + for (File f : files) { + if (f.isFile()) { + out.add(decodePath(f.getName())); + } + } + return out.toArray(new String[out.size()]); + } + + public void transferFile(String path, String name, byte[] contents) { + // The desktop has no background-transfer scheduler worth simulating, and a transfer that + // arrives eventually is indistinguishable from a data write that arrives eventually. + putData(path + "/" + (name == null ? "file" : name), contents); + } + + // --- rendezvous --------------------------------------------------------- + + /// Both sides race to bind the loopback port; the winner listens, the loser connects and retries + /// until the winner exists. Which side wins does not matter, which means the phone and the watch + /// can be started in either order. + private void startRendezvous() { + Thread t = new Thread(new Runnable() { + public void run() { + ServerSocket server = null; + try { + server = new ServerSocket(port(), 1, InetAddress.getByName("127.0.0.1")); + } catch (IOException alreadyBound) { + server = null; + } + if (server != null) { + acceptLoop(server); + } else { + connectLoop(); + } + } + }, "CN1 wearable link"); + t.setDaemon(true); + t.start(); + } + + private void acceptLoop(ServerSocket server) { + while (!closed) { + try { + Socket s = server.accept(); + adoptPeer(s); + readLoop(s); + } catch (IOException err) { + if (closed) { + return; + } + } + } + } + + private void connectLoop() { + while (!closed) { + try { + Socket s = new Socket(InetAddress.getByName("127.0.0.1"), port()); + adoptPeer(s); + readLoop(s); + } catch (IOException notUpYet) { + // The peer app is not running. Wait and retry -- the user may open it at any point. + } + if (closed) { + return; + } + try { + Thread.sleep(1000); + } catch (InterruptedException ignored) { + return; + } + } + } + + private void adoptPeer(Socket s) throws IOException { + s.setTcpNoDelay(true); + peer = s; + peerOut = new DataOutputStream(s.getOutputStream()); + writeFrame(peerOut, FRAME_HELLO, "", new byte[0], 0); + WearableConnection.notifyStateChanged(); + } + + private void readLoop(Socket s) { + try { + DataInputStream in = new DataInputStream(s.getInputStream()); + while (!closed) { + int kind = in.readByte(); + String path = in.readUTF(); + int token = in.readInt(); + byte[] payload = new byte[in.readInt()]; + in.readFully(payload); + switch (kind) { + case FRAME_MESSAGE: + WearableConnection.deliverMessage(path, payload, token); + break; + case FRAME_REPLY: + WearableConnection.deliverReply(token, payload, null); + break; + default: + break; + } + } + } catch (IOException disconnected) { + // Falls through to dropPeer: the peer app exited or the link broke. + } finally { + dropPeer(); + } + } + + private void dropPeer() { + Socket s = peer; + peer = null; + peerOut = null; + if (s != null) { + try { + s.close(); + } catch (IOException ignored) { + } + WearableConnection.notifyStateChanged(); + } + } + + private static void writeFrame(DataOutputStream out, int kind, String path, + byte[] payload, int token) throws IOException { + byte[] body = payload == null ? new byte[0] : payload; + synchronized (out) { + out.writeByte(kind); + out.writeUTF(path == null ? "" : path); + out.writeInt(token); + out.writeInt(body.length); + out.write(body); + out.flush(); + } + } + + /// Derives a stable loopback port from the shared directory, so two JVMs of the same project + /// meet and two different projects do not. Kept in the ephemeral range. + private int port() { + int h = dataDir.getAbsolutePath().hashCode(); + return 49152 + Math.abs(h % 10000); + } + + // --- data watching ------------------------------------------------------ + + /// Notices values the peer published. Polling is enough here: the peer writes rarely, the + /// directory is tiny, and this stays honest about replicated data being eventually consistent. + private void startDataWatcher() { + Thread t = new Thread(new Runnable() { + public void run() { + while (!closed) { + scanData(); + try { + Thread.sleep(500); + } catch (InterruptedException ignored) { + return; + } + } + } + }, "CN1 wearable data"); + t.setDaemon(true); + t.start(); + } + + /// Records what is already on disk without reporting it, so a restart does not replay every + /// value the app itself published last run. + private void primeSeenData() { + File[] files = dataDir.listFiles(); + if (files == null) { + return; + } + synchronized (seenData) { + for (File f : files) { + if (f.isFile()) { + seenData.put(f.getName(), new Long(f.lastModified())); + } + } + } + } + + private void scanData() { + File[] files = dataDir.listFiles(); + List gone; + synchronized (seenData) { + gone = new ArrayList(seenData.keySet()); + } + if (files != null) { + for (File f : files) { + if (!f.isFile()) { + continue; + } + gone.remove(f.getName()); + Long previous; + synchronized (seenData) { + previous = seenData.get(f.getName()); + } + long stamp = f.lastModified(); + if (previous != null && previous.longValue() == stamp) { + continue; + } + synchronized (seenData) { + seenData.put(f.getName(), new Long(stamp)); + } + try { + WearableConnection.deliverDataChanged(decodePath(f.getName()), readFully(f)); + } catch (IOException stillBeingWritten) { + // Re-reported on the next pass once the writer has finished. + synchronized (seenData) { + seenData.remove(f.getName()); + } + } + } + } + for (String name : gone) { + synchronized (seenData) { + seenData.remove(name); + } + WearableConnection.deliverDataRemoved(decodePath(name)); + } + } + + // --- helpers ------------------------------------------------------------ + + private File dataFile(String path) { + return new File(dataDir, encodePath(path)); + } + + /// Paths are URL-ish (`/workout/start`) and must survive a round trip through a file name on a + /// case-insensitive file system, so everything outside a conservative set is percent-escaped. + private static String encodePath(String path) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '-') { + sb.append(c); + } else { + sb.append('%').append(Integer.toHexString(0x10000 | c).substring(1)); + } + } + return sb.toString(); + } + + private static String decodePath(String name) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (c == '%' && i + 4 < name.length()) { + sb.append((char) Integer.parseInt(name.substring(i + 1, i + 5), 16)); + i += 4; + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static byte[] readFully(File f) throws IOException { + FileInputStream in = new FileInputStream(f); + try { + byte[] out = new byte[(int) f.length()]; + int read = 0; + while (read < out.length) { + int n = in.read(out, read, out.length - read); + if (n < 0) { + throw new IOException("Truncated while reading " + f); + } + read += n; + } + return out; + } finally { + in.close(); + } + } + + /// Stops the link. Called when the simulator shuts down. + void close() { + closed = true; + dropPeer(); + } +} diff --git a/tools/watch-skins/GenerateWatchSkins.java b/tools/watch-skins/GenerateWatchSkins.java new file mode 100644 index 00000000000..7f8e09d33a8 --- /dev/null +++ b/tools/watch-skins/GenerateWatchSkins.java @@ -0,0 +1,189 @@ +import java.awt.*; +import java.awt.geom.RoundRectangle2D; +import java.awt.image.BufferedImage; +import java.io.*; +import java.util.zip.*; +import javax.imageio.ImageIO; + +/** + * Generates the Apple Watch and Wear OS simulator skins the Codename One JavaSE + * simulator ships, so a watch layout can be developed on the desktop instead of + * only on a device. + * + * These are functional development skins -- correct display geometry, the round + * flag, honest safe-area insets and {@code watch=true} so {@code CN.isWatch()} + * is true and the "watch" override layer applies -- with simple programmatically + * drawn bezel artwork. Replace skin.png with final design art when available. + * + * Each generated *.skin is a ZIP containing: skin.png, skin_l.png, + * skin.properties and a theme .res copied from the bundled iPhoneX.skin. + * + * Regenerate the shipped skins with: + * javac -d /tmp/wskin tools/watch-skins/GenerateWatchSkins.java + * java -cp /tmp/wskin GenerateWatchSkins Ports/JavaSE/src/iPhoneX.skin Ports/JavaSE/src + */ +public class GenerateWatchSkins { + static class Model { + final String file, label; + final int dw, dh; // display size in points + final boolean circular; // a round Wear OS face rather than a rounded rectangle + final String platformName; // drives the skin's platform overrides + final String overrides; + Model(String file, String label, int dw, int dh, boolean circular, + String platformName, String overrides) { + this.file = file; this.label = label; this.dw = dw; this.dh = dh; + this.circular = circular; this.platformName = platformName; this.overrides = overrides; + } + } + + public static void main(String[] args) throws Exception { + File srcSkin = new File(args[0]); + File outDir = new File(args[1]); + outDir.mkdirs(); + + byte[] themeRes = extractEntry(srcSkin, ".res"); + if (themeRes == null) { + throw new IllegalStateException("No .res theme found in " + srcSkin); + } + + Model[] models = new Model[] { + // Apple Watch logical point resolutions. + new Model("AppleWatch41mm.skin", "Apple Watch 41mm", 352, 430, false, + "ios", "watch,ios,applewatch"), + new Model("AppleWatch45mm.skin", "Apple Watch 45mm", 396, 484, false, + "ios", "watch,ios,applewatch"), + // Wear OS. The round face is the one worth designing against: it is what most Wear + // hardware ships and it is where a layout that assumes a rectangle falls apart. + new Model("WearRound.skin", "Wear OS Round", 454, 454, true, + "and", "watch,android,android-watch"), + new Model("WearSquare.skin", "Wear OS Square", 400, 400, false, + "and", "watch,android,android-watch"), + }; + + for (Model m : models) { + generate(m, themeRes, outDir); + System.out.println("Wrote " + new File(outDir, m.file)); + } + } + + static void generate(Model m, byte[] themeRes, File outDir) throws Exception { + // Bezel margins around the display; the crown sits on the right edge. + int marginX = 70, marginTop = 90, marginBottom = 90; + int imgW = m.dw + marginX * 2; + int imgH = m.dh + marginTop + marginBottom; + int displayX = marginX; + int displayY = marginTop; + + BufferedImage skin = new BufferedImage(imgW, imgH, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = skin.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + // Transparent backdrop. + g.setComposite(AlphaComposite.Clear); + g.fillRect(0, 0, imgW, imgH); + g.setComposite(AlphaComposite.SrcOver); + + // Aluminium body: rounded rectangle the size of the whole image. + int bodyArc = Math.min(imgW, imgH) / 3; + g.setColor(new Color(0x1c1c1e)); + g.fill(new RoundRectangle2D.Float(0, 0, imgW, imgH, bodyArc, bodyArc)); + + // Subtle bezel highlight. + g.setStroke(new BasicStroke(3f)); + g.setColor(new Color(0x3a3a3c)); + g.draw(new RoundRectangle2D.Float(6, 6, imgW - 12, imgH - 12, bodyArc - 6, bodyArc - 6)); + + // Rotary input nub on the right edge: the Digital Crown on Apple, the rotating side button + // on Wear. Both scroll the focused container, so the artwork says the same thing. + g.setColor(new Color(0x5a5a5e)); + g.fillRoundRect(imgW - 10, imgH / 2 - 34, 16, 68, 10, 10); + // Side button below the crown. + g.fillRoundRect(imgW - 8, imgH / 2 + 48, 12, 54, 8, 8); + + // The display recess (the rest of the screen is painted by the simulator). + g.setColor(Color.BLACK); + if (m.circular) { + g.fillOval(displayX, displayY, m.dw, m.dh); + } else { + g.fill(new RoundRectangle2D.Float(displayX, displayY, m.dw, m.dh, 56, 56)); + } + g.dispose(); + + // Watch never rotates; landscape image reuses the portrait artwork. + ByteArrayOutputStream png = new ByteArrayOutputStream(); + ImageIO.write(skin, "png", png); + byte[] skinPng = png.toByteArray(); + + // Safe-area inset: the curve eats the corners, so content has to stay clear of them. A round + // face loses far more than a rounded rectangle does -- inscribing a rectangle in a circle + // costs about 15% a side -- and getting this wrong in the simulator is precisely the bug + // that only shows up on real hardware. + int inset = Math.round(m.dh * (m.circular ? 0.15f : 0.06f)); + StringBuilder p = new StringBuilder(); + p.append("# ").append(m.label).append(" - Codename One simulator skin (placeholder art)\n"); + p.append("touch=true\n"); + p.append("ppi=326\n"); + p.append("smallFontSize=").append(Math.round(m.dw * 0.045f)).append('\n'); + p.append("mediumFontSize=").append(Math.round(m.dw * 0.06f)).append('\n'); + p.append("largeFontSize=").append(Math.round(m.dw * 0.08f)).append('\n'); + p.append("systemFontFamily=Helvetica Neue\n"); + p.append("proportionalFontFamily=Helvetica Neue\n"); + p.append("monospaceFontFamily=Courier\n"); + p.append("keyboardType=3\n"); + p.append("softbuttonCount=0\n"); + p.append("platformName=").append(m.platformName).append('\n'); + p.append("overrideNames=").append(m.overrides).append('\n'); + p.append("watch=true\n"); + // Only a genuinely circular face is a round screen. Apple Watch is a heavily rounded + // rectangle, and claiming otherwise would inscribe its safe area in a circle and waste a + // third of the display. + p.append("roundScreen=").append(m.circular).append('\n'); + p.append("displayX=").append(displayX).append('\n'); + p.append("displayY=").append(displayY).append('\n'); + p.append("displayWidth=").append(m.dw).append('\n'); + p.append("displayHeight=").append(m.dh).append('\n'); + p.append("safePortraitX=0\n"); + p.append("safePortraitY=").append(inset).append('\n'); + p.append("safePortraitWidth=").append(m.dw).append('\n'); + p.append("safePortraitHeight=").append(m.dh - inset * 2).append('\n'); + p.append("safeLandscapeX=").append(inset).append('\n'); + p.append("safeLandscapeY=0\n"); + p.append("safeLandscapeWidth=").append(m.dh - inset * 2).append('\n'); + p.append("safeLandscapeHeight=").append(m.dw).append('\n'); + + File out = new File(outDir, m.file); + ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(out)); + putEntry(zos, "skin.png", skinPng); + putEntry(zos, "skin_l.png", skinPng); + putEntry(zos, "skin.properties", p.toString().getBytes("UTF-8")); + putEntry(zos, "iOS7Theme.res", themeRes); + zos.close(); + } + + static void putEntry(ZipOutputStream zos, String name, byte[] data) throws IOException { + zos.putNextEntry(new ZipEntry(name)); + zos.write(data); + zos.closeEntry(); + } + + static byte[] extractEntry(File zip, String suffix) throws IOException { + ZipInputStream z = new ZipInputStream(new FileInputStream(zip)); + ZipEntry e; + try { + while ((e = z.getNextEntry()) != null) { + if (e.getName().endsWith(suffix)) { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = z.read(buf)) > 0) { + b.write(buf, 0, n); + } + return b.toByteArray(); + } + } + } finally { + z.close(); + } + return null; + } +} From 4d070a3c0e9aa08298acdf9d92814fd870930183 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:13:27 +0300 Subject: [PATCH 003/250] Carry com.codename1.wearable over WatchConnectivity on Apple CN1WatchConnectivity is the WCSession delegate behind the phone-to-watch API. The same file compiles into both the phone target and the watch target: WCSession is symmetric, so the two halves of a pair run identical code and the Java API behaves identically at both ends. The three transports land where they belong -- sendMessage on sendMessage:replyHandler:, replicated data on the session's application context (which survives both apps being killed and is handed to the peer whenever it next runs), and transferFile on transferFile:metadata:. Payloads cross as opaque bytes, so the native layer never has to understand the value model. Reply blocks for inbound messages are parked until the Java side has hopped to the EDT and answered, which is what lets a listener do real work rather than having to respond inside the delegate callback. Gated by API scan like CarPlay and surfaces before it: the builder defines CN1_USE_WATCHCONNECTIVITY and links WatchConnectivity.framework only when the app references com.codename1.wearable, so apps that never talk to a watch carry no WCSession symbols. Unlike the CarPlay and widgets defines this one deliberately survives on the watch slice -- that is the half that needs it most. It is undone on tvOS and Mac Catalyst, where WatchConnectivity does not exist. Mirrored to the BuildDaemon. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/CN1WatchConnectivity.h | 98 ++++++ .../nativeSources/CN1WatchConnectivity.m | 300 ++++++++++++++++++ .../CodenameOne_GLViewController.h | 11 + Ports/iOSPort/nativeSources/IOSNative.m | 250 +++++++++++++++ .../codename1/impl/ios/IOSImplementation.java | 14 + .../src/com/codename1/impl/ios/IOSNative.java | 47 +++ .../codename1/impl/ios/IOSWearableBridge.java | 112 +++++++ .../impl/ios/IOSWearableCallbacks.java | 100 ++++++ .../com/codename1/builders/IPhoneBuilder.java | 33 ++ 9 files changed, 965 insertions(+) create mode 100644 Ports/iOSPort/nativeSources/CN1WatchConnectivity.h create mode 100644 Ports/iOSPort/nativeSources/CN1WatchConnectivity.m create mode 100644 Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableBridge.java create mode 100644 Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableCallbacks.java diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.h b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.h new file mode 100644 index 00000000000..78ccb6625b9 --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.h @@ -0,0 +1,98 @@ +/* + * 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. + */ + +// WatchConnectivity glue backing com.codename1.wearable on Apple. +// +// The same file compiles into BOTH the phone target and the watch target: WCSession is symmetric, +// so the phone half and the watch half of a pair run identical code and the Java API is identical +// on both ends. WatchConnectivity is unavailable on tvOS and Mac Catalyst, and the whole file is +// additionally gated on CN1_USE_WATCHCONNECTIVITY, which the build defines only when the app +// references com.codename1.wearable -- apps that do not pay nothing and link no framework. +// +// Everything below moves opaque byte payloads; the value model lives in Java +// (com.codename1.wearable.WearableMessage), so this layer never has to understand it. + +#ifndef CN1WatchConnectivity_h +#define CN1WatchConnectivity_h + +#include "TargetConditionals.h" + +#if defined(CN1_USE_WATCHCONNECTIVITY) && !TARGET_OS_TV && !TARGET_OS_MACCATALYST + +#import +#import + +@interface CN1WatchConnectivity : NSObject + +/// Returns the shared instance, activating the WCSession on first use. ++ (CN1WatchConnectivity *)shared; + +/// True when this device supports the link at all. False on an iPad, and on an iPhone whose +/// WCSession is not supported. +- (BOOL)isSupported; + +/// True when a counterpart device is paired. Always true from the watch side, which by definition +/// has a phone. +- (BOOL)isPaired; + +/// True when the peer app can receive a live message right now. +- (BOOL)isReachable; + +/// True when the counterpart app is installed on the paired device. +- (BOOL)isCompanionInstalled; + +/// Sends a live message. A non-zero replyToken asks the peer for an answer, which comes back through +/// cn1_wearable_deliverReply. +- (void)sendMessage:(NSString *)path payload:(NSData *)payload replyToken:(int)replyToken; + +/// Answers a message that arrived carrying a reply token. +- (void)sendReply:(int)replyToken payload:(NSData *)payload; + +/// Publishes or replaces the replicated value at a path. +- (void)putData:(NSString *)path payload:(NSData *)payload; + +/// Returns the replicated value at a path, or nil. +- (NSData *)getData:(NSString *)path; + +/// Removes the replicated value at a path. +- (void)removeData:(NSString *)path; + +/// Returns every path currently holding a replicated value. +- (NSArray *)dataPaths; + +/// Queues a file transfer to the peer. +- (void)transferFile:(NSString *)path name:(NSString *)name contents:(NSData *)contents; + +@end + +#endif // CN1_USE_WATCHCONNECTIVITY + +// Entry points into the Java side, implemented in IOSNative.m so this file needs no knowledge of +// the VM. No-ops when the feature is compiled out. +void cn1_wearable_deliverMessage(const char *path, const void *payload, int payloadLength, int replyToken); +void cn1_wearable_deliverReply(int replyToken, const void *payload, int payloadLength, const char *error); +void cn1_wearable_deliverDataChanged(const char *path, const void *payload, int payloadLength); +void cn1_wearable_deliverDataRemoved(const char *path); +void cn1_wearable_notifyStateChanged(void); + +#endif /* CN1WatchConnectivity_h */ diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m new file mode 100644 index 00000000000..0b4689837af --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -0,0 +1,300 @@ +/* + * 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 "CN1WatchConnectivity.h" + +#if defined(CN1_USE_WATCHCONNECTIVITY) && !TARGET_OS_TV && !TARGET_OS_MACCATALYST + +// Keys inside the dictionaries WCSession carries. WCSession moves property lists, and the Java +// payload is opaque bytes, so every transfer is a two-entry dictionary: the path it is addressed to +// and the bytes themselves. +static NSString *const kPathKey = @"cn1.path"; +static NSString *const kBodyKey = @"cn1.body"; +static NSString *const kTokenKey = @"cn1.token"; +static NSString *const kReplyKey = @"cn1.reply"; + +@implementation CN1WatchConnectivity { + // Reply blocks for messages the peer sent us that expect an answer. The Java side answers + // asynchronously on the EDT, so the block has to outlive the delegate callback. + NSMutableDictionary *)> *_pendingReplies; + int _nextInboundToken; +} + ++ (CN1WatchConnectivity *)shared { + static CN1WatchConnectivity *instance = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + instance = [[CN1WatchConnectivity alloc] init]; + [instance activate]; + }); + return instance; +} + +- (instancetype)init { + self = [super init]; + if (self != nil) { + _pendingReplies = [[NSMutableDictionary alloc] init]; + _nextInboundToken = 1; + } + return self; +} + +- (void)activate { + if ([WCSession isSupported]) { + WCSession *s = [WCSession defaultSession]; + s.delegate = self; + [s activate]; + } +} + +- (WCSession *)session { + return [WCSession isSupported] ? [WCSession defaultSession] : nil; +} + +// --- state --------------------------------------------------------------- + +- (BOOL)isSupported { + return [WCSession isSupported]; +} + +- (BOOL)isPaired { +#if TARGET_OS_WATCH + // The watch always has a phone; there is no isPaired on this side. + return [WCSession isSupported]; +#else + WCSession *s = [self session]; + return s != nil && s.isPaired; +#endif +} + +- (BOOL)isReachable { + WCSession *s = [self session]; + return s != nil && s.reachable; +} + +- (BOOL)isCompanionInstalled { + WCSession *s = [self session]; + if (s == nil) { + return NO; + } +#if TARGET_OS_WATCH + return s.isCompanionAppInstalled; +#else + return s.isWatchAppInstalled; +#endif +} + +// --- messages ------------------------------------------------------------ + +- (void)sendMessage:(NSString *)path payload:(NSData *)payload replyToken:(int)replyToken { + WCSession *s = [self session]; + if (s == nil || !s.reachable) { + if (replyToken != 0) { + cn1_wearable_deliverReply(replyToken, NULL, 0, "The peer app is not reachable"); + } + return; + } + NSDictionary *msg = @{kPathKey: (path == nil ? @"" : path), + kBodyKey: (payload == nil ? [NSData data] : payload)}; + if (replyToken == 0) { + [s sendMessage:msg replyHandler:nil errorHandler:^(NSError *error) { + // Nothing to report: the sender asked for no answer, so a failure here is the same + // "dropped because unreachable" the API documents. + }]; + return; + } + [s sendMessage:msg replyHandler:^(NSDictionary *reply) { + NSData *body = reply[kReplyKey]; + cn1_wearable_deliverReply(replyToken, body.bytes, (int) body.length, NULL); + } errorHandler:^(NSError *error) { + cn1_wearable_deliverReply(replyToken, NULL, 0, + error.localizedDescription.UTF8String); + }]; +} + +- (void)sendReply:(int)replyToken payload:(NSData *)payload { + void (^handler)(NSDictionary *); + @synchronized (_pendingReplies) { + NSNumber *key = @(replyToken); + handler = _pendingReplies[key]; + [_pendingReplies removeObjectForKey:key]; + } + if (handler != nil) { + handler(@{kReplyKey: (payload == nil ? [NSData data] : payload)}); + } +} + +// --- replicated data ----------------------------------------------------- + +// Replicated data is the session's application context: one dictionary that survives both apps +// being killed and is handed to the peer whenever it next runs. Each CN1 path is one entry, so +// publishing a path replaces only that path. + +- (void)putData:(NSString *)path payload:(NSData *)payload { + WCSession *s = [self session]; + if (s == nil || path == nil) { + return; + } + NSMutableDictionary *ctx = [[s applicationContext] mutableCopy]; + if (ctx == nil) { + ctx = [NSMutableDictionary dictionary]; + } + ctx[path] = (payload == nil ? [NSData data] : payload); + NSError *err = nil; + [s updateApplicationContext:ctx error:&err]; + if (err != nil) { + NSLog(@"[cn1.wearable] failed to publish %@: %@", path, err.localizedDescription); + } +} + +- (NSData *)getData:(NSString *)path { + WCSession *s = [self session]; + if (s == nil || path == nil) { + return nil; + } + // Our own published values live in applicationContext; values the peer published arrive in + // receivedApplicationContext. A reader wants whichever exists, most-recent-wins on our side. + NSData *mine = [s applicationContext][path]; + return mine != nil ? mine : [s receivedApplicationContext][path]; +} + +- (void)removeData:(NSString *)path { + WCSession *s = [self session]; + if (s == nil || path == nil) { + return; + } + NSMutableDictionary *ctx = [[s applicationContext] mutableCopy]; + if (ctx == nil || ctx[path] == nil) { + return; + } + [ctx removeObjectForKey:path]; + NSError *err = nil; + [s updateApplicationContext:ctx error:&err]; +} + +- (NSArray *)dataPaths { + WCSession *s = [self session]; + if (s == nil) { + return @[]; + } + NSMutableSet *paths = [NSMutableSet setWithArray:[s applicationContext].allKeys]; + [paths addObjectsFromArray:[s receivedApplicationContext].allKeys]; + return paths.allObjects; +} + +- (void)transferFile:(NSString *)path name:(NSString *)name contents:(NSData *)contents { + WCSession *s = [self session]; + if (s == nil || contents == nil) { + return; + } + NSString *dir = NSTemporaryDirectory(); + NSString *file = [dir stringByAppendingPathComponent: + (name.length > 0 ? name : @"cn1-wearable-transfer")]; + if (![contents writeToFile:file atomically:YES]) { + NSLog(@"[cn1.wearable] could not stage %@ for transfer", file); + return; + } + [s transferFile:[NSURL fileURLWithPath:file] + metadata:@{kPathKey: (path == nil ? @"" : path)}]; +} + +// --- WCSessionDelegate --------------------------------------------------- + +- (void)session:(WCSession *)session + activationDidCompleteWithState:(WCSessionActivationState)activationState + error:(NSError *)error { + cn1_wearable_notifyStateChanged(); +} + +#if !TARGET_OS_WATCH +- (void)sessionDidBecomeInactive:(WCSession *)session { + cn1_wearable_notifyStateChanged(); +} + +- (void)sessionDidDeactivate:(WCSession *)session { + // The user switched to a different watch. Re-activating is what keeps the link alive. + [session activate]; + cn1_wearable_notifyStateChanged(); +} + +- (void)sessionWatchStateDidChange:(WCSession *)session { + cn1_wearable_notifyStateChanged(); +} +#else +- (void)sessionCompanionAppInstalledDidChange:(WCSession *)session { + cn1_wearable_notifyStateChanged(); +} +#endif + +- (void)sessionReachabilityDidChange:(WCSession *)session { + cn1_wearable_notifyStateChanged(); +} + +- (void)session:(WCSession *)session didReceiveMessage:(NSDictionary *)message { + [self dispatchInbound:message reply:nil]; +} + +- (void)session:(WCSession *)session + didReceiveMessage:(NSDictionary *)message + replyHandler:(void (^)(NSDictionary *))replyHandler { + [self dispatchInbound:message reply:replyHandler]; +} + +- (void)dispatchInbound:(NSDictionary *)message + reply:(void (^)(NSDictionary *))replyHandler { + NSString *path = message[kPathKey]; + NSData *body = message[kBodyKey]; + int token = 0; + if (replyHandler != nil) { + // Park the block so the Java side can answer after it has hopped to the EDT. + @synchronized (_pendingReplies) { + token = _nextInboundToken++; + _pendingReplies[@(token)] = [replyHandler copy]; + } + } + cn1_wearable_deliverMessage(path.UTF8String, body.bytes, (int) body.length, token); +} + +- (void)session:(WCSession *)session + didReceiveApplicationContext:(NSDictionary *)applicationContext { + // The peer replaced its whole context; report every entry and let the Java listeners decide + // what changed. Contexts are small by design, so this is cheaper than diffing. + for (NSString *path in applicationContext) { + NSData *body = applicationContext[path]; + if ([body isKindOfClass:[NSData class]]) { + cn1_wearable_deliverDataChanged(path.UTF8String, body.bytes, (int) body.length); + } + } +} + +- (void)session:(WCSession *)session didReceiveFile:(WCSessionFile *)file { + NSString *path = file.metadata[kPathKey]; + NSData *body = [NSData dataWithContentsOfURL:file.fileURL]; + if (body != nil) { + cn1_wearable_deliverDataChanged(path.UTF8String, body.bytes, (int) body.length); + } +} + +@end + +#endif // CN1_USE_WATCHCONNECTIVITY diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index b22fa97e66e..60bc8967c9a 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -148,6 +148,17 @@ void cn1RunSyncOnMainQueue(void (^block)(void)); #undef CN1_USE_WIDGETS #endif +// CN1_USE_WATCHCONNECTIVITY gates the phone-to-watch link (CN1WatchConnectivity.{h,m} + the +// IOSNative wearable* trampolines) backing com.codename1.wearable. IPhoneBuilder uncomments this +// only when the classpath scanner saw com.codename1.wearable.*, so apps that never talk to a watch +// ship without any WatchConnectivity symbols and link no framework. Unlike the defines above this +// one deliberately SURVIVES on watchOS: WCSession is symmetric, and the watch half of a pair needs +// exactly the same code as the phone half. It does not exist on tvOS or Mac Catalyst. +//#define CN1_USE_WATCHCONNECTIVITY +#if TARGET_OS_TV || TARGET_OS_MACCATALYST +#undef CN1_USE_WATCHCONNECTIVITY +#endif + // CN1_INCLUDE_OIDC gates the com.codename1.io.oidc native bridge // (AuthenticationServices.framework import, ASWebAuthenticationSession code // in CN1OidcBrowser.m). IPhoneBuilder uncomments this only when the diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index ef60c5babc3..64509ee6f73 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -14969,6 +14969,256 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesActivitiesSupported___R_bo return com_codename1_impl_ios_IOSNative_surfacesActivitiesSupported__(CN1_THREAD_STATE_PASS_ARG instanceObject); } +// --- Phone-to-watch link (com.codename1.wearable / WatchConnectivity) -------- +// +// Compiled into BOTH the phone target and the watch target: WCSession is symmetric, so the two +// halves of a pair run identical code. Gated on CN1_USE_WATCHCONNECTIVITY, which the builder +// defines only when the app references com.codename1.wearable, so other apps link no framework and +// carry no symbols. Payloads cross as opaque bytes; the value model lives in Java. + +#if defined(CN1_USE_WATCHCONNECTIVITY) && !TARGET_OS_TV && !TARGET_OS_MACCATALYST + +#import "CN1WatchConnectivity.h" + +// 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. + +void cn1_wearable_deliverMessage(const char *path, const void *payload, int payloadLength, int replyToken) { + JAVA_OBJECT jPath = path == NULL ? JAVA_NULL : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [NSString stringWithUTF8String:path]); + JAVA_OBJECT jBody = payload == NULL ? JAVA_NULL + : nsDataToByteArr([NSData dataWithBytes:payload length:payloadLength]); + com_codename1_impl_ios_IOSWearableCallbacks_nativeMessageReceived___java_lang_String_byte_1ARRAY_int( + CN1_THREAD_GET_STATE_PASS_ARG jPath, jBody, replyToken); +} + +void cn1_wearable_deliverReply(int replyToken, const void *payload, int payloadLength, const char *error) { + JAVA_OBJECT jBody = payload == NULL ? JAVA_NULL + : nsDataToByteArr([NSData dataWithBytes:payload length:payloadLength]); + JAVA_OBJECT jError = error == NULL ? JAVA_NULL + : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [NSString stringWithUTF8String:error]); + com_codename1_impl_ios_IOSWearableCallbacks_nativeReplyReceived___int_byte_1ARRAY_java_lang_String( + CN1_THREAD_GET_STATE_PASS_ARG replyToken, jBody, jError); +} + +void cn1_wearable_deliverDataChanged(const char *path, const void *payload, int payloadLength) { + JAVA_OBJECT jPath = path == NULL ? JAVA_NULL : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [NSString stringWithUTF8String:path]); + JAVA_OBJECT jBody = payload == NULL ? JAVA_NULL + : nsDataToByteArr([NSData dataWithBytes:payload length:payloadLength]); + com_codename1_impl_ios_IOSWearableCallbacks_nativeDataChanged___java_lang_String_byte_1ARRAY( + CN1_THREAD_GET_STATE_PASS_ARG jPath, jBody); +} + +void cn1_wearable_deliverDataRemoved(const char *path) { + JAVA_OBJECT jPath = path == NULL ? JAVA_NULL : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [NSString stringWithUTF8String:path]); + com_codename1_impl_ios_IOSWearableCallbacks_nativeDataRemoved___java_lang_String( + CN1_THREAD_GET_STATE_PASS_ARG jPath); +} + +void cn1_wearable_notifyStateChanged(void) { + com_codename1_impl_ios_IOSWearableCallbacks_nativeStateChanged__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); +} + +// Turns a Java byte[] into NSData. A null array becomes empty data rather than nil so the callers +// never have to branch. +static NSData *cn1WearableToNSData(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT arr) { + if (arr == JAVA_NULL) { + return [NSData data]; + } + JAVA_ARRAY byteArray = (JAVA_ARRAY) arr; + JAVA_ARRAY_BYTE *data = (JAVA_ARRAY_BYTE *) byteArray->data; + return [NSData dataWithBytes:data length:byteArray->length]; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); + BOOL b = [[CN1WatchConnectivity shared] isSupported]; + POOL_END(); + return b ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearablePaired__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); + BOOL b = [[CN1WatchConnectivity shared] isPaired]; + POOL_END(); + return b ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableReachable__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); + BOOL b = [[CN1WatchConnectivity shared] isReachable]; + POOL_END(); + return b ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableCompanionInstalled__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); + BOOL b = [[CN1WatchConnectivity shared] isCompanionInstalled]; + POOL_END(); + return b ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearablePeerName__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); + // WCSession exposes no peer name, so name the form factor: from the phone the peer is the + // watch, from the watch it is the phone. +#if TARGET_OS_WATCH + JAVA_OBJECT r = fromNSString(CN1_THREAD_STATE_PASS_ARG @"iPhone"); +#else + JAVA_OBJECT r = fromNSString(CN1_THREAD_STATE_PASS_ARG @"Apple Watch"); +#endif + POOL_END(); + return r; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearablePeerId__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); +#if TARGET_OS_WATCH + JAVA_OBJECT r = fromNSString(CN1_THREAD_STATE_PASS_ARG @"phone"); +#else + JAVA_OBJECT r = fromNSString(CN1_THREAD_STATE_PASS_ARG @"watch"); +#endif + POOL_END(); + return r; +} + +void com_codename1_impl_ios_IOSNative_wearableSendMessage___java_lang_String_byte_1ARRAY_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path, JAVA_OBJECT payload, JAVA_INT replyToken) { + POOL_BEGIN(); + NSString *p = path == JAVA_NULL ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG path); + [[CN1WatchConnectivity shared] sendMessage:p + payload:cn1WearableToNSData(CN1_THREAD_STATE_PASS_ARG payload) + replyToken:(int) replyToken]; + POOL_END(); +} + +void com_codename1_impl_ios_IOSNative_wearableSendReply___int_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT replyToken, JAVA_OBJECT payload) { + POOL_BEGIN(); + [[CN1WatchConnectivity shared] sendReply:(int) replyToken + payload:cn1WearableToNSData(CN1_THREAD_STATE_PASS_ARG payload)]; + POOL_END(); +} + +void com_codename1_impl_ios_IOSNative_wearablePutData___java_lang_String_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path, JAVA_OBJECT payload) { + POOL_BEGIN(); + NSString *p = path == JAVA_NULL ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG path); + [[CN1WatchConnectivity shared] putData:p + payload:cn1WearableToNSData(CN1_THREAD_STATE_PASS_ARG payload)]; + POOL_END(); +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearableGetData___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path) { + POOL_BEGIN(); + NSString *p = path == JAVA_NULL ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG path); + NSData *d = [[CN1WatchConnectivity shared] getData:p]; + JAVA_OBJECT r = d == nil ? JAVA_NULL : nsDataToByteArr(d); + POOL_END(); + return r; +} + +void com_codename1_impl_ios_IOSNative_wearableRemoveData___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path) { + POOL_BEGIN(); + NSString *p = path == JAVA_NULL ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG path); + [[CN1WatchConnectivity shared] removeData:p]; + POOL_END(); +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearableDataPaths__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); + NSArray *paths = [[CN1WatchConnectivity shared] dataPaths]; + JAVA_OBJECT r = fromNSString(CN1_THREAD_STATE_PASS_ARG [paths componentsJoinedByString:@"\n"]); + POOL_END(); + return r; +} + +void com_codename1_impl_ios_IOSNative_wearableTransferFile___java_lang_String_java_lang_String_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path, JAVA_OBJECT name, JAVA_OBJECT contents) { + POOL_BEGIN(); + NSString *p = path == JAVA_NULL ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG path); + NSString *n = name == JAVA_NULL ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG name); + [[CN1WatchConnectivity shared] transferFile:p + name:n + contents:cn1WearableToNSData(CN1_THREAD_STATE_PASS_ARG contents)]; + POOL_END(); +} + +#else // CN1_USE_WATCHCONNECTIVITY + +// The app never references com.codename1.wearable (or this is tvOS / Mac Catalyst, where +// WatchConnectivity does not exist). No framework is linked and everything answers unsupported, +// which makes the public API an inert no-op. + +void cn1_wearable_deliverMessage(const char *path, const void *payload, int payloadLength, int replyToken) { +} +void cn1_wearable_deliverReply(int replyToken, const void *payload, int payloadLength, const char *error) { +} +void cn1_wearable_deliverDataChanged(const char *path, const void *payload, int payloadLength) { +} +void cn1_wearable_deliverDataRemoved(const char *path) { +} +void cn1_wearable_notifyStateChanged(void) { +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearablePaired__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableReachable__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableCompanionInstalled__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearablePeerName__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_NULL; +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearablePeerId__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_NULL; +} +void com_codename1_impl_ios_IOSNative_wearableSendMessage___java_lang_String_byte_1ARRAY_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path, JAVA_OBJECT payload, JAVA_INT replyToken) { +} +void com_codename1_impl_ios_IOSNative_wearableSendReply___int_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT replyToken, JAVA_OBJECT payload) { +} +void com_codename1_impl_ios_IOSNative_wearablePutData___java_lang_String_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path, JAVA_OBJECT payload) { +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearableGetData___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path) { + return JAVA_NULL; +} +void com_codename1_impl_ios_IOSNative_wearableRemoveData___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path) { +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearableDataPaths__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_NULL; +} +void com_codename1_impl_ios_IOSNative_wearableTransferFile___java_lang_String_java_lang_String_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT path, JAVA_OBJECT name, JAVA_OBJECT contents) { +} + +#endif // CN1_USE_WATCHCONNECTIVITY + +// Return-typed aliases the translator emits for methods with a non-void return. +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_wearableSupported__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearablePaired___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_wearablePaired__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableReachable___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_wearableReachable__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_wearableCompanionInstalled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_wearableCompanionInstalled__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearablePeerName___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_wearablePeerName__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearablePeerId___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_wearablePeerId__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearableGetData___java_lang_String_R_byte_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT path) { + return com_codename1_impl_ios_IOSNative_wearableGetData___java_lang_String(CN1_THREAD_STATE_PASS_ARG instanceObject, path); +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_wearableDataPaths___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_wearableDataPaths__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} + void com_codename1_impl_ios_IOSNative_setSecureStorageAccessGroup___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT accessGroup) { if (cn1_keychainAccessGroup != nil) { [cn1_keychainAccessGroup release]; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index dc327326e4b..ed73dcab15f 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -368,6 +368,20 @@ public boolean isCarConnected() { return nativeInstance.isCarPlayConnected(); } + private IOSWearableBridge wearableBridge; + + @Override + public com.codename1.wearable.spi.WearableBridge getWearableBridge() { + // Only meaningful in builds that linked the WatchConnectivity natives + // (CN1_USE_WATCHCONNECTIVITY, flipped by the builder when the app references + // com.codename1.wearable). Always returned: the bridge's own isSupported() answers honestly + // through the natives, which stub to unsupported when the define is off. + if (wearableBridge == null) { + wearableBridge = IOSWearableCallbacks.getBridge(nativeInstance); + } + return wearableBridge; + } + private IOSSurfaceBridge surfaceBridge; @Override diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index b8477540459..acf972e3289 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -1096,6 +1096,53 @@ native void walletExtensionAddPassEntry(boolean remote, String identifier, Strin /** True when ActivityKit live activities are available and enabled (iOS 16.1+). */ native boolean surfacesActivitiesSupported(); + // --- Phone-to-watch link (WatchConnectivity) ---------------------------- + // Backs com.codename1.wearable. The same natives serve both halves of a pair: WCSession is + // symmetric, so the phone app and the watch app run identical code. Payloads cross as opaque + // bytes; the value model lives in com.codename1.wearable.WearableMessage. + + /** True when this device supports a phone-to-watch link at all (false on iPad). */ + native boolean wearableSupported(); + + /** True when a counterpart device is paired, in range or not. */ + native boolean wearablePaired(); + + /** True when the peer app can receive a live message right now. */ + native boolean wearableReachable(); + + /** True when the counterpart app is installed on the paired device. */ + native boolean wearableCompanionInstalled(); + + /** The paired device's name, for display. Empty when nothing is paired. */ + native String wearablePeerName(); + + /** The paired device's opaque identifier. Empty when nothing is paired. */ + native String wearablePeerId(); + + /** + * Sends a live message, delivered only while the peer is reachable. A non-zero + * {@code replyToken} asks for an answer, which comes back through {@code IOSWearableCallbacks}. + */ + native void wearableSendMessage(String path, byte[] payload, int replyToken); + + /** Answers a message that arrived carrying a reply token. */ + native void wearableSendReply(int replyToken, byte[] payload); + + /** Publishes or replaces the replicated value at a path (the WCSession application context). */ + native void wearablePutData(String path, byte[] payload); + + /** Reads the replicated value at a path, published by either side. Null when absent. */ + native byte[] wearableGetData(String path); + + /** Removes the replicated value at a path. */ + native void wearableRemoveData(String path); + + /** Every path currently holding a replicated value, newline separated. */ + native String wearableDataPaths(); + + /** Queues a background file transfer to the peer. */ + native void wearableTransferFile(String path, String name, byte[] contents); + // --- Secure storage (Security.framework keychain) ----------------------- /** Sets the kSecAttrAccessGroup applied to subsequent keychain operations. {@code null} clears. */ diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableBridge.java new file mode 100644 index 00000000000..e58a23a9fbd --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableBridge.java @@ -0,0 +1,112 @@ +/* + * 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.ios; + +import com.codename1.wearable.spi.WearableBridge; + +/// Apple `WearableBridge`, backing `com.codename1.wearable` with `WCSession`. +/// +/// The same class runs on both halves of a pair: WatchConnectivity is symmetric, so the phone app +/// and the watch app use identical code and the Java API behaves identically at both ends. The three +/// transports map onto WCSession as follows: +/// +/// - a live message is `sendMessage:replyHandler:`, delivered only while the peer is reachable; +/// - replicated data is the session's application context, which survives both apps being killed and +/// is handed to the peer whenever it next runs; +/// - a file transfer is `transferFile:metadata:`, which the system schedules in the background. +/// +/// Payloads cross as opaque bytes, so the native layer never has to understand the value model. +/// +/// This whole class is dead code unless the build linked the WatchConnectivity natives (the +/// `CN1_USE_WATCHCONNECTIVITY` define the builder flips when the app references +/// `com.codename1.wearable`); without it every native answers unsupported and the public API no-ops. +final class IOSWearableBridge implements WearableBridge { + private final IOSNative nativeInstance; + + IOSWearableBridge(IOSNative nativeInstance) { + this.nativeInstance = nativeInstance; + } + + public boolean isSupported() { + return nativeInstance.wearableSupported(); + } + + public boolean isPaired() { + return nativeInstance.wearablePaired(); + } + + public boolean isReachable() { + return nativeInstance.wearableReachable(); + } + + public boolean isCompanionAppInstalled() { + return nativeInstance.wearableCompanionInstalled(); + } + + public String[] getConnectedNodes() { + if (!isReachable()) { + // WCSession has no node list -- Apple pairs exactly one watch -- so the peer is either + // there or it is not, and "there" is what reachable means. + return new String[0]; + } + String name = nativeInstance.wearablePeerName(); + String id = nativeInstance.wearablePeerId(); + return new String[] {(id == null ? "peer" : id) + "\t" + + (name == null ? "Paired device" : name) + "\t1"}; + } + + public void sendMessage(String path, byte[] payload, int replyToken) { + nativeInstance.wearableSendMessage(path, payload, replyToken); + } + + public void sendReply(int replyToken, byte[] payload) { + nativeInstance.wearableSendReply(replyToken, payload); + } + + public void putData(String path, byte[] payload) { + nativeInstance.wearablePutData(path, payload); + } + + public byte[] getData(String path) { + return nativeInstance.wearableGetData(path); + } + + public void removeData(String path) { + nativeInstance.wearableRemoveData(path); + } + + public String[] getDataPaths() { + String joined = nativeInstance.wearableDataPaths(); + if (joined == null || joined.length() == 0) { + return new String[0]; + } + // Newline-separated: a CN1 path is URL-shaped and never contains one, and a single string + // keeps the native signature to primitives. + java.util.List parts = com.codename1.util.StringUtil.tokenize(joined, '\n'); + return parts.toArray(new String[parts.size()]); + } + + public void transferFile(String path, String name, byte[] contents) { + nativeInstance.wearableTransferFile(path, name, contents); + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableCallbacks.java new file mode 100644 index 00000000000..a132b8e2f5d --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableCallbacks.java @@ -0,0 +1,100 @@ +/* + * 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.ios; + +import com.codename1.wearable.WearableConnection; + +/// Static callback surface invoked from `CN1WatchConnectivity` when the peer app sends something. +/// +/// Mirrors the `IOSSurfaceCallbacks` pattern: the static initializer calls each callback once +/// (guarded so it has no effect) purely to keep the ParparVM dead-code eliminator from stripping +/// targets that have no Java caller. Everything here forwards straight to +/// `WearableConnection`, which owns EDT dispatch and the cold-start queue. +final class IOSWearableCallbacks { + private static IOSWearableBridge bridge; + private static boolean dceGuard; + + static { + // Keep the native callback targets reachable for the iOS VM optimizer. + dceGuard = true; + nativeMessageReceived(null, null, 0); + nativeReplyReceived(0, null, null); + nativeDataChanged(null, null); + nativeDataRemoved(null); + nativeStateChanged(); + dceGuard = false; + } + + private IOSWearableCallbacks() { + } + + /// Returns the singleton wearable bridge, creating it on first use. + static synchronized IOSWearableBridge getBridge(IOSNative nativeInstance) { + if (bridge == null) { + bridge = new IOSWearableBridge(nativeInstance); + } + return bridge; + } + + // ---- Callbacks invoked from native code (do not rename) ---------------- + + /// Called from native when the peer app sends a live message. + static void nativeMessageReceived(String path, byte[] payload, int replyToken) { + if (dceGuard) { + return; + } + WearableConnection.deliverMessage(path, payload, replyToken); + } + + /// Called from native with the peer's answer to a message that asked for one. + static void nativeReplyReceived(int replyToken, byte[] payload, String error) { + if (dceGuard) { + return; + } + WearableConnection.deliverReply(replyToken, payload, error); + } + + /// Called from native when the peer publishes or updates a replicated value. + static void nativeDataChanged(String path, byte[] payload) { + if (dceGuard) { + return; + } + WearableConnection.deliverDataChanged(path, payload); + } + + /// Called from native when the peer removes a replicated value. + static void nativeDataRemoved(String path) { + if (dceGuard) { + return; + } + WearableConnection.deliverDataRemoved(path); + } + + /// Called from native when reachability, pairing or peer-app installation changes. + static void nativeStateChanged() { + if (dceGuard) { + return; + } + WearableConnection.notifyStateChanged(); + } +} 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 ea285f42feb..a9cf18f6d20 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 @@ -135,6 +135,11 @@ public class IPhoneBuilder extends Executor { private boolean surfacesLiveActivities; private final List surfacesKinds = new ArrayList(); + // Set when the app references com.codename1.wearable.* (the phone-to-watch link). Gates the + // CN1_USE_WATCHCONNECTIVITY native define and WatchConnectivity.framework linkage on both the + // phone target and the watch target -- WCSession is symmetric, so both halves of a pair need + // it. Apps that never touch the API see no change. + private boolean usesWearable; private boolean usesOidc; private boolean usesAppleSignIn; private boolean usesWebauthn; @@ -887,6 +892,12 @@ public void usesClass(String cls) { if (!usesSurfaces && cls.indexOf("com/codename1/surfaces/") == 0) { usesSurfaces = true; } + // Phone-to-watch link (com.codename1.wearable.*). Gated on actual usage + // so WatchConnectivity.framework and the CN1_USE_WATCHCONNECTIVITY + // natives are only added for apps that talk to their watch app. + if (!usesWearable && cls.indexOf("com/codename1/wearable/") == 0) { + usesWearable = true; + } // OidcClient + SystemBrowser rely on // ASWebAuthenticationSession (AuthenticationServices.framework, // iOS 12+). @@ -2110,6 +2121,15 @@ public void usesClassMethod(String cls, String method) { replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_WIDGETS", "#define CN1_USE_WIDGETS"); } + // com.codename1.wearable usage compiles the WatchConnectivity glue (gated by + // CN1_USE_WATCHCONNECTIVITY so other builds carry no WCSession symbols). The define + // 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) { + replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_WATCHCONNECTIVITY", "#define CN1_USE_WATCHCONNECTIVITY"); + } + String glAppDelegeateBody = request.getArg("ios.glAppDelegateBody", null); if (glAppDelegeateBody != null && glAppDelegeateBody.length() > 0) { replaceInFile(glAppDelegate, "//GL_APP_DELEGATE_BODY", glAppDelegeateBody); @@ -2495,6 +2515,19 @@ public void usesClassMethod(String cls, String method) { // Apple per app category, so we only inject the ones the project opts into via the // ios.carplay. build hints; the binary references CarPlay symbols (gated by // CN1_USE_CARPLAY) which is why the framework is linked here in lockstep with the scan. + // The phone-to-watch link references WCSession (gated by CN1_USE_WATCHCONNECTIVITY), so + // 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) { + String wearableLib = "WatchConnectivity.framework"; + if (addLibs == null || addLibs.length() == 0) { + addLibs = wearableLib; + } else if (!addLibs.toLowerCase().contains("watchconnectivity.framework")) { + addLibs = addLibs + ";" + wearableLib; + } + } + if (usesCar) { String carPlayLibs = "CarPlay.framework;MediaPlayer.framework"; if (addLibs == null || addLibs.length() == 0) { From aeef6414ecd0ac8ca55de7aa1e773ede95346a26 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:31:43 +0300 Subject: [PATCH 004/250] Wear OS: Data Layer link, rotary input and round-screen safe area Three things a Wear OS app needs that the port did not provide. The Data Layer bridge is the Android half of com.codename1.wearable. It is injected into the generated project rather than living in the port, because the port cannot reference play-services-wearable -- the same reason the Android Auto glue is injected. The three transports land where they belong: a live message on MessageClient (nearby nodes only), replicated data on a DataItem marked urgent so the system does not sit on it for minutes, and a file on a background-synced DataItem. MessageClient is one-way, so a request carries its reply token in the path and the answer comes back on a matching reply path, which is what makes the reply handler behave identically to WCSession's. Unlike Apple, Wear allows several watches on one phone, so sends fan out to every connected node. The listener service is what Android starts to deliver a message when the app is not running -- exactly the case the API's cold-start queue exists for. Rotary input: the rotating side button and bezel report on SOURCE_ROTARY_ENCODER / AXIS_SCROLL, which onGenericMotionEvent did not read -- it handled only the mouse axes, so a Wear app could not scroll at all. It now feeds the same wheel path the Digital Crown uses, scaled by the device's own scroll factor. Round-screen safe area: a circular face reports no display cutout, so the safe area came back zero and a layout drawn to the full rectangle had its corners eaten by the bezel. The largest rectangle inside a circle loses about 14.6% a side, and that is now reserved on top of whatever the system asks for. Mirrored to the BuildDaemon. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/android/AndroidImplementation.java | 268 ++++++++-------- .../impl/android/AndroidWearableSupport.java | 79 +++++ .../impl/android/CodenameOneView.java | 89 +++++- .../builders/AndroidGradleBuilder.java | 52 ++++ .../builders/wearable/CN1WearableBridge.java | 290 ++++++++++++++++++ .../wearable/CN1WearableListenerService.java | 110 +++++++ 6 files changed, 754 insertions(+), 134 deletions(-) create mode 100644 Ports/Android/src/com/codename1/impl/android/AndroidWearableSupport.java create mode 100644 maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableBridge.java create mode 100644 maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableListenerService.java diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index cf0e724c03b..a5caba82cf9 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -221,7 +221,7 @@ import java.security.MessageDigest; import java.text.ParseException; import java.util.*; -import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicLong; import javax.net.ssl.HttpsURLConnection; import javax.xml.parsers.ParserConfigurationException; @@ -231,10 +231,10 @@ import org.xml.sax.SAXException; //import android.webkit.JavascriptInterface; -public class AndroidImplementation extends CodenameOneImplementation implements IntentResultListener { - private AndroidCalendarSource calendarSource; - private static final AtomicLong V3_NOTIFICATION_SEQUENCE = new AtomicLong(); - +public class AndroidImplementation extends CodenameOneImplementation implements IntentResultListener { + private AndroidCalendarSource calendarSource; + private static final AtomicLong V3_NOTIFICATION_SEQUENCE = new AtomicLong(); + public static final Thread.UncaughtExceptionHandler exceptionHandler = new Thread.UncaughtExceptionHandler() { @Override public void uncaughtException(Thread t, Throwable e) { @@ -821,94 +821,94 @@ private static byte[] readInputStream(InputStream i) throws IOException { } - public static void appendNotification(String type, String body, Context a) { + public static void appendNotification(String type, String body, Context a) { appendNotification(type, body, null, null, a); - } - - /** Receives the managed typed envelope from FCM without applying legacy push decoding. */ - public static void handleV3Push(final String envelope, Context context, - boolean appRunning, Class appStubClass) { - if (appRunning && Display.isInitialized() - && com.codename1.push.PushClient.hasActiveClient()) { - Display.getInstance().callSerially(new Runnable() { - public void run() { - com.codename1.push.PushClient.dispatch(envelope); - } - }); - return; - } - try { - org.json.JSONObject message = new org.json.JSONObject(envelope); - // The pending-push file explicitly encodes whether a legacy type is present. - // A missing type is the sentinel for a typed V3 envelope and is replayed intact. - appendNotification(null, envelope, context); - if (message.optBoolean("silent", false)) { - return; - } - String title = message.optString("title", ""); - String body = message.optString("body", ""); - String image = message.optString("image", ""); - if (title.length() == 0 && body.length() == 0 && image.length() == 0) { - return; - } - if (title.length() == 0) { - title = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString(); - } - Intent intent = new Intent(context, appStubClass); - PendingIntent contentIntent = createPendingIntent(context, 0, intent); - int smallIcon = context.getResources().getIdentifier("ic_stat_notify", "drawable", - context.getPackageName()); - if (smallIcon == 0) { - smallIcon = context.getApplicationInfo().icon; - } - NotificationCompat.Builder builder = new NotificationCompat.Builder(context) - .setContentTitle(title) - .setContentText(body) - .setSmallIcon(smallIcon) - .setContentIntent(contentIntent) - .setAutoCancel(true) - .setWhen(System.currentTimeMillis()); - NotificationManager manager = (NotificationManager) - context.getSystemService(Context.NOTIFICATION_SERVICE); - setNotificationChannel(manager, builder, context); - String collapseKey = message.optString("collapseKey", null); - String messageId = message.optString("id", null); - String notificationTag; - if (collapseKey != null && collapseKey.length() > 0) { - notificationTag = v3NotificationTag("CN1_PUSH_V3_COLLAPSE:", collapseKey); - } else if (messageId != null && messageId.length() > 0) { - notificationTag = v3NotificationTag("CN1_PUSH_V3_MESSAGE:", messageId); - } else { - notificationTag = "CN1_PUSH_V3_EPHEMERAL:" + System.currentTimeMillis() - + ":" + V3_NOTIFICATION_SEQUENCE.incrementAndGet(); - } - manager.notify(notificationTag, 0, builder.build()); - } catch (Exception error) { - Log.e("Codename One", "Failed to handle a Push V3 envelope", error); - } - } - - private static String v3NotificationTag(String prefix, String value) { - if (prefix.length() + value.length() <= 128) { - return prefix + value; - } - try { - byte[] digest = MessageDigest.getInstance("SHA-256") - .digest(value.getBytes(StandardCharsets.UTF_8)); - StringBuilder out = new StringBuilder(prefix.length() + digest.length * 2); - out.append(prefix); - for (byte item : digest) { - int unsigned = item & 0xff; - if (unsigned < 0x10) { - out.append('0'); - } - out.append(Integer.toHexString(unsigned)); - } - return out.toString(); - } catch (Exception error) { - return prefix + Integer.toHexString(value.hashCode()); - } - } + } + + /** Receives the managed typed envelope from FCM without applying legacy push decoding. */ + public static void handleV3Push(final String envelope, Context context, + boolean appRunning, Class appStubClass) { + if (appRunning && Display.isInitialized() + && com.codename1.push.PushClient.hasActiveClient()) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + com.codename1.push.PushClient.dispatch(envelope); + } + }); + return; + } + try { + org.json.JSONObject message = new org.json.JSONObject(envelope); + // The pending-push file explicitly encodes whether a legacy type is present. + // A missing type is the sentinel for a typed V3 envelope and is replayed intact. + appendNotification(null, envelope, context); + if (message.optBoolean("silent", false)) { + return; + } + String title = message.optString("title", ""); + String body = message.optString("body", ""); + String image = message.optString("image", ""); + if (title.length() == 0 && body.length() == 0 && image.length() == 0) { + return; + } + if (title.length() == 0) { + title = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString(); + } + Intent intent = new Intent(context, appStubClass); + PendingIntent contentIntent = createPendingIntent(context, 0, intent); + int smallIcon = context.getResources().getIdentifier("ic_stat_notify", "drawable", + context.getPackageName()); + if (smallIcon == 0) { + smallIcon = context.getApplicationInfo().icon; + } + NotificationCompat.Builder builder = new NotificationCompat.Builder(context) + .setContentTitle(title) + .setContentText(body) + .setSmallIcon(smallIcon) + .setContentIntent(contentIntent) + .setAutoCancel(true) + .setWhen(System.currentTimeMillis()); + NotificationManager manager = (NotificationManager) + context.getSystemService(Context.NOTIFICATION_SERVICE); + setNotificationChannel(manager, builder, context); + String collapseKey = message.optString("collapseKey", null); + String messageId = message.optString("id", null); + String notificationTag; + if (collapseKey != null && collapseKey.length() > 0) { + notificationTag = v3NotificationTag("CN1_PUSH_V3_COLLAPSE:", collapseKey); + } else if (messageId != null && messageId.length() > 0) { + notificationTag = v3NotificationTag("CN1_PUSH_V3_MESSAGE:", messageId); + } else { + notificationTag = "CN1_PUSH_V3_EPHEMERAL:" + System.currentTimeMillis() + + ":" + V3_NOTIFICATION_SEQUENCE.incrementAndGet(); + } + manager.notify(notificationTag, 0, builder.build()); + } catch (Exception error) { + Log.e("Codename One", "Failed to handle a Push V3 envelope", error); + } + } + + private static String v3NotificationTag(String prefix, String value) { + if (prefix.length() + value.length() <= 128) { + return prefix + value; + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder out = new StringBuilder(prefix.length() + digest.length * 2); + out.append(prefix); + for (byte item : digest) { + int unsigned = item & 0xff; + if (unsigned < 0x10) { + out.append('0'); + } + out.append(Integer.toHexString(unsigned)); + } + return out.toString(); + } catch (Exception error) { + return prefix + Integer.toHexString(value.hashCode()); + } + } public static void appendNotification(String type, String body, String image, String category, Context a) { try { @@ -6271,6 +6271,14 @@ public boolean isCarConnected() { return b != null && b.isConnected(); } + @Override + public com.codename1.wearable.spi.WearableBridge getWearableBridge() { + // The Wearable Data Layer glue is injected by the builder only when the app references + // com.codename1.wearable; without it this is null and the API no-ops. + Context ctx = getContext(); + return ctx == null ? null : AndroidWearableSupport.getBridge(ctx); + } + private com.codename1.surfaces.spi.SurfaceBridge surfaceBridge; @Override @@ -8411,20 +8419,20 @@ public boolean isContactsPermissionGranted() { @Override - public String[] getAllContacts(boolean withNumbers) { + public String[] getAllContacts(boolean withNumbers) { if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ return new String[]{}; } return AndroidContactsManager.getInstance().getContacts(getContext(), withNumbers); - } - - @Override - public com.codename1.calendar.LocalCalendarSource getLocalCalendarSource() { - if (calendarSource == null) { - calendarSource = new AndroidCalendarSource(getContext()); - } - return calendarSource; - } + } + + @Override + public com.codename1.calendar.LocalCalendarSource getLocalCalendarSource() { + if (calendarSource == null) { + calendarSource = new AndroidCalendarSource(getContext()); + } + return calendarSource; + } @Override public Contact getContactById(String id) { @@ -9026,10 +9034,10 @@ private ClipData enrichClipWithBinaryContent(ClipboardContent content, ClipData imageExt = "gif"; } if (imageBytes != null) { - // AndroidGradleBuilder exposes cache/intent_files through the app's - // FileProvider. Keep generated clipboard payloads inside that root so - // FileProvider can safely create a content:// URI for paste targets. - File imageFile = new File(new File(getContext().getCacheDir(), "intent_files"), + // AndroidGradleBuilder exposes cache/intent_files through the app's + // FileProvider. Keep generated clipboard payloads inside that root so + // FileProvider can safely create a content:// URI for paste targets. + File imageFile = new File(new File(getContext().getCacheDir(), "intent_files"), "cn1-clip-image-" + System.currentTimeMillis() + "." + imageExt); imageFile.getParentFile().mkdirs(); OutputStream os = new FileOutputStream(imageFile); @@ -9063,14 +9071,14 @@ private ClipData enrichClipWithBinaryContent(ClipboardContent content, ClipData continue; } Uri u; - if (pathOrUri.startsWith("content:")) { - u = Uri.parse(pathOrUri); - } else { - File file = pathOrUri.startsWith("file:") - ? new File(Uri.parse(pathOrUri).getPath()) - : new File(pathOrUri); - u = FileProvider.getUriForFile(getContext(), authority, file); - getContext().grantUriPermission("android", u, Intent.FLAG_GRANT_READ_URI_PERMISSION); + if (pathOrUri.startsWith("content:")) { + u = Uri.parse(pathOrUri); + } else { + File file = pathOrUri.startsWith("file:") + ? new File(Uri.parse(pathOrUri).getPath()) + : new File(pathOrUri); + u = FileProvider.getUriForFile(getContext(), authority, file); + getContext().grantUriPermission("android", u, Intent.FLAG_GRANT_READ_URI_PERMISSION); } if (clip == null) { clip = new ClipData("Codename One", new String[]{ "text/uri-list" }, new ClipData.Item(u)); @@ -10593,7 +10601,7 @@ public static boolean hasAndroidMarket(Context activity) { } @Override - public void registerPush(Hashtable metaData, boolean noFallback) { + public void registerPush(Hashtable metaData, boolean noFallback) { if (getActivity() == null) { return; } @@ -10604,18 +10612,18 @@ public void registerPush(Hashtable metaData, boolean noFallback) { } } - boolean huawei = "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", "")); - if (!hasAndroidMarket() && !huawei) { - Log.d("Codename One", "Device doesn't have Android market/google play can't register for push!"); - return; - } - String id = ""; - if (!huawei) { - id = (String)metaData.get(com.codename1.push.Push.GOOGLE_PUSH_KEY); - if (id == null) { - id = Display.getInstance().getProperty("gcm.sender_id", null); - } - } + boolean huawei = "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", "")); + if (!hasAndroidMarket() && !huawei) { + Log.d("Codename One", "Device doesn't have Android market/google play can't register for push!"); + return; + } + String id = ""; + if (!huawei) { + id = (String)metaData.get(com.codename1.push.Push.GOOGLE_PUSH_KEY); + if (id == null) { + id = Display.getInstance().getProperty("gcm.sender_id", null); + } + } Log.d("Codename One", "Sending async push request for id: " + id); ((CodenameOneActivity) getActivity()).registerForPush(id); } @@ -10629,9 +10637,9 @@ public static void registerPolling() { } @Override - public void deregisterPush() { - boolean has = hasAndroidMarket() - || "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", "")); + public void deregisterPush() { + boolean has = hasAndroidMarket() + || "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", "")); if (has) { ((CodenameOneActivity) getActivity()).stopReceivingPush(); deregisterPushFromServer(); diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidWearableSupport.java b/Ports/Android/src/com/codename1/impl/android/AndroidWearableSupport.java new file mode 100644 index 00000000000..f4e96e02a04 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidWearableSupport.java @@ -0,0 +1,79 @@ +/* + * 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; + +import com.codename1.wearable.spi.WearableBridge; + +/// Registry that links the Android port to the Wearable Data Layer glue. +/// +/// The runtime Android port carries no compile-time dependency on +/// `com.google.android.gms:play-services-wearable` -- it is only on the classpath when the app +/// references `com.codename1.wearable`, at which point the build injects a typed `WearableBridge` +/// implementation plus a `WearableListenerService` into the generated project. The injected bridge +/// registers itself here and `AndroidImplementation#getWearableBridge()` reads it back. Without the +/// glue this stays null and the `com.codename1.wearable` API degrades to a no-op, exactly as it does +/// on a phone with no watch. +/// +/// This mirrors {@link AndroidCarSupport}, for the same reason: an optional Google dependency cannot +/// be referenced from the port itself. +/// +/// The injected glue lives in the maven-plugin / BuildDaemon resources under +/// `com/codename1/builders/wearable/`. +public final class AndroidWearableSupport { + private static volatile WearableBridge bridge; + private static boolean lookedUp; + + private AndroidWearableSupport() { + } + + /// Returns the injected bridge, or null when the app does not use the wearable API. + /// + /// Unlike the in-car glue -- which the system instantiates, so it can register itself -- nothing + /// creates the wearable bridge on our behalf, so it is looked up reflectively on first use. The + /// class only exists in the generated project when the build injected it, which is precisely the + /// condition under which play-services-wearable is on the classpath. + /// + /// #### Parameters + /// + /// - `context`: the Android context the bridge needs + /// + /// #### Returns + /// + /// the wearable bridge, or null + public static synchronized WearableBridge getBridge(android.content.Context context) { + if (!lookedUp) { + lookedUp = true; + try { + Class c = Class.forName("com.codename1.impl.android.CN1WearableBridge"); + bridge = (WearableBridge) c.getConstructor(android.content.Context.class) + .newInstance(context); + } catch (ClassNotFoundException notInjected) { + // The app never references com.codename1.wearable; the API stays inert. + } catch (Throwable err) { + com.codename1.io.Log.p("Wearable: the Data Layer glue is present but could not be " + + "created: " + err); + } + } + return bridge; + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java b/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java index 1db1cd53088..fb606218f9d 100644 --- a/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java +++ b/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java @@ -264,6 +264,41 @@ public void run() { rect.right = 0; rect.bottom = 0; } + applyRoundScreenInset(rect); + } + + /** + * Widens the safe area to clear the curve on a round Wear OS display. + * + * A round watch face reports no display cutout, so everything above leaves the safe area at + * zero and a layout drawn to the full rectangle has its corners cut off by the bezel. The + * largest rectangle that fits inside a circle of diameter d has side d/sqrt(2), so each edge + * loses about 14.6% -- that is what is reserved here, on top of whatever the system already + * asked for. + */ + private void applyRoundScreenInset(Rect rect) { + if (!isRoundScreen()) { + return; + } + int d = Math.min(this.width, this.height); + if (d <= 0) { + return; + } + int inset = (int) Math.ceil(d * (1 - 1 / Math.sqrt(2)) / 2); + rect.left = Math.max(rect.left, inset); + rect.top = Math.max(rect.top, inset); + rect.right = Math.max(rect.right, inset); + rect.bottom = Math.max(rect.bottom, inset); + } + + /** True on a circular watch face, which is most Wear OS hardware. */ + private boolean isRoundScreen() { + try { + return this.implementation.getActivity().getResources() + .getConfiguration().isScreenRound(); + } catch (Throwable preApi23) { + return false; + } } public void handleSizeChange(int w, int h) { @@ -702,23 +737,39 @@ public boolean onHoverEvent(MotionEvent event) { * Routes Android generic motion events into Codename One. This captures the * mouse wheel and trackpad scroll axes (vertical and horizontal) from * external pointing devices (BT mouse, Chromebook trackpad, DeX) which are - * not delivered through onTouchEvent. + * not delivered through onTouchEvent, and the Wear OS rotary input (the + * rotating side button / bezel) which reports on a different axis again. */ public boolean onGenericMotionEvent(MotionEvent event) { if (this.implementation.getCurrentForm() == null) { return false; } if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) { + int x = (int) event.getX(); + int y = (int) event.getY(); + int step = this.implementation.convertToPixels(20, true); + + // Wear OS rotary input arrives from SOURCE_ROTARY_ENCODER on AXIS_SCROLL, not on the + // mouse axes below -- a watch app that only handled those could not scroll at all. It + // is the Digital Crown's counterpart, so it feeds the same wheel path, and Android + // scales it by the device's own scroll factor rather than a fixed step. + if (isRotaryEncoder(event)) { + float rotary = event.getAxisValue(MotionEvent.AXIS_SCROLL); + if (rotary == 0) { + return false; + } + int scrollY = Math.round(-rotary * rotaryScrollFactor(step)); + this.implementation.pointerWheelMoved(x, y, 0, scrollY, true, motionModifierMask(event)); + return true; + } + float vscroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL); float hscroll = event.getAxisValue(MotionEvent.AXIS_HSCROLL); if (vscroll == 0 && hscroll == 0) { return false; } - int x = (int) event.getX(); - int y = (int) event.getY(); // A positive scrollY reveals content above (drag down); Android reports a // positive VSCROLL when scrolling away from the user, so negate to match. - int step = this.implementation.convertToPixels(20, true); int scrollY = Math.round(-vscroll * step); int scrollX = Math.round(-hscroll * step); this.implementation.pointerWheelMoved(x, y, scrollX, scrollY, true, motionModifierMask(event)); @@ -727,6 +778,36 @@ public boolean onGenericMotionEvent(MotionEvent event) { return false; } + /** + * True when the event came from the Wear OS rotary input. SOURCE_ROTARY_ENCODER and AXIS_SCROLL + * both arrived in API 23, which is also the Wear OS standalone baseline, so older devices + * simply never match. + */ + private static boolean isRotaryEncoder(MotionEvent event) { + if (android.os.Build.VERSION.SDK_INT < 23) { + return false; + } + return (event.getSource() & InputDevice.SOURCE_ROTARY_ENCODER) == InputDevice.SOURCE_ROTARY_ENCODER; + } + + /** + * How many pixels one detent of rotary travel should scroll. Android publishes a per-device + * factor for exactly this; fall back to the shared wheel step when it is unavailable so the + * gesture still does something sensible. + */ + private float rotaryScrollFactor(int fallbackStep) { + try { + float f = ViewConfiguration.get(this.implementation.getActivity()) + .getScaledVerticalScrollFactor(); + if (f > 0) { + return f; + } + } catch (Throwable notAvailable) { + // Pre-API-26 or an unusual device configuration. + } + return fallbackStep; + } + /** * Translates the Android MotionEvent tool type, pressure, contact size, tilt * and button state into the cross-platform pointer metadata so the 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 38aa3dc7e0f..99b0e6902fb 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 @@ -310,6 +310,10 @@ 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; + // Set when the app references com.codename1.wearable.* (the phone-to-watch link). Gates the + // play-services-wearable dependency, the WearableListenerService manifest entry and the + // injected Data Layer glue. + private boolean usesWearable; private boolean usesOidc; private boolean usesAppleSignIn; private boolean usesWebauthn; @@ -1466,6 +1470,13 @@ public void usesClass(String cls) { usesSurfaces = true; } + // Phone-to-watch link (com.codename1.wearable.*). Gated on actual usage so the + // play-services-wearable dependency, the listener service and the injected Data + // Layer glue are only added for apps that talk to their watch app. + if (!usesWearable && cls.indexOf("com/codename1/wearable/") == 0) { + usesWearable = true; + } + if (cls.equals("com/codename1/background/ForegroundService")) { usesForegroundService = true; } @@ -2296,6 +2307,29 @@ public void usesClassMethod(String cls, String method) { } } + // Wearable Data Layer glue: when the app references com.codename1.wearable, copy the + // injected WearableBridge + WearableListenerService (typed against play-services-wearable) + // into the generated project and add the dependency. The Android port itself cannot + // reference play-services-wearable, which is why these ship as .java resources here and are + // only added for apps that talk to a watch. + if (usesWearable) { + File wearImpl = new File(srcDir, "com/codename1/impl/android"); + wearImpl.mkdirs(); + String[] glue = {"CN1WearableBridge.java", "CN1WearableListenerService.java"}; + for (String g : glue) { + InputStream gin = getResourceAsStream("/com/codename1/builders/wearable/" + g); + if (gin == null) { + throw new BuildException("Missing wearable glue resource " + g); + } + try { + copy(gin, new FileOutputStream(new File(wearImpl, g))); + } catch (IOException ex) { + throw new BuildException("Failed to write wearable glue " + g, ex); + } + } + playServicesWear = true; + } + // 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 @@ -3200,6 +3234,23 @@ public void usesClassMethod(String cls, String method) { } } + // The Data Layer starts this service to deliver a message or a data change even when the + // app is not running -- which is the whole point, and why com.codename1.wearable queues + // callbacks across a cold start. Both the message and data-changed actions are needed: the + // system dispatches them separately. + String wearableListenerService = ""; + if (usesWearable) { + wearableListenerService = + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n"; + } + if (foregroundServicePermission) { permissions += permissionAdd(request, "\"android.permission.FOREGROUND_SERVICE\"", " \n"); @@ -3591,6 +3642,7 @@ public void usesClassMethod(String cls, String method) { + remoteControlService + hceService + carAppService + + wearableListenerService + surfacesManifestEntries + " \n" + " \n" 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 new file mode 100644 index 00000000000..4b5db11c9a3 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableBridge.java @@ -0,0 +1,290 @@ +/* + * 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; + +import android.content.Context; +import android.net.Uri; + +import com.codename1.wearable.WearableConnection; +import com.codename1.wearable.spi.WearableBridge; + +import com.google.android.gms.tasks.Tasks; +import com.google.android.gms.wearable.CapabilityClient; +import com.google.android.gms.wearable.CapabilityInfo; +import com.google.android.gms.wearable.DataClient; +import com.google.android.gms.wearable.DataItem; +import com.google.android.gms.wearable.DataItemBuffer; +import com.google.android.gms.wearable.MessageClient; +import com.google.android.gms.wearable.Node; +import com.google.android.gms.wearable.NodeClient; +import com.google.android.gms.wearable.PutDataRequest; +import com.google.android.gms.wearable.Wearable; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Wearable Data Layer implementation of the Codename One {@code WearableBridge}, injected into the + * generated project only when the app references {@code com.codename1.wearable}. The Android port + * itself carries no dependency on play-services-wearable, which is why this class lives in the + * builder's resources rather than in the port -- see {@link AndroidWearableSupport}. + * + *

The three Codename One transports map onto the Data Layer as follows: + *

    + *
  • a live message is {@code MessageClient.sendMessage}, delivered only to nearby nodes;
  • + *
  • replicated data is a {@code DataItem} at the given path, which the system syncs to every + * paired node whenever it next connects, surviving both apps being killed;
  • + *
  • a file transfer is a DataItem carrying an {@code Asset}, which the system streams in the + * background.
  • + *
+ * + *

Unlike Apple, Wear allows several watches paired to one phone, so sends fan out to every + * connected node. Payloads are the opaque bytes produced by {@code WearableMessage}, so nothing here + * has to understand the value model. + */ +public class CN1WearableBridge implements WearableBridge { + /** Data Layer paths must start with a slash, and so do Codename One paths by convention. */ + private static final String PATH_PREFIX = "/cn1"; + /** The key the payload bytes live under inside a DataItem. */ + private static final String PAYLOAD_KEY = "cn1.payload"; + /** How long a blocking Data Layer call may take before we give up and answer "not available". */ + private static final long TIMEOUT_SECONDS = 5; + + private final Context context; + private final MessageClient messageClient; + private final DataClient dataClient; + private final NodeClient nodeClient; + private final CapabilityClient capabilityClient; + + /** + * Reply blocks are not a Data Layer concept: MessageClient is one-way. A request carries its + * token in the path and the answer comes back on a reply path carrying the same token, which is + * what lets the Codename One reply handler work identically on both platforms. + */ + private static final String REPLY_PATH = PATH_PREFIX + "/reply/"; + private static final String REQUEST_PATH = PATH_PREFIX + "/request/"; + private static final String MESSAGE_PATH = PATH_PREFIX + "/message"; + + public CN1WearableBridge(Context context) { + this.context = context.getApplicationContext(); + this.messageClient = Wearable.getMessageClient(this.context); + this.dataClient = Wearable.getDataClient(this.context); + this.nodeClient = Wearable.getNodeClient(this.context); + this.capabilityClient = Wearable.getCapabilityClient(this.context); + } + + // --- state -------------------------------------------------------------- + + public boolean isSupported() { + return true; + } + + public boolean isPaired() { + return !connectedNodes().isEmpty(); + } + + public boolean isReachable() { + for (Node n : connectedNodes()) { + if (n.isNearby()) { + return true; + } + } + return false; + } + + public boolean isCompanionAppInstalled() { + // A node only appears in the Data Layer's node list when it is running a build of this same + // app, so a connected node is the same answer. + return !connectedNodes().isEmpty(); + } + + public String[] getConnectedNodes() { + List nodes = connectedNodes(); + String[] out = new String[nodes.size()]; + for (int i = 0; i < out.length; i++) { + Node n = nodes.get(i); + // id \t displayName \t nearby -- the flat form the SPI documents. + out[i] = n.getId() + "\t" + n.getDisplayName() + "\t" + (n.isNearby() ? "1" : "0"); + } + return out; + } + + private List connectedNodes() { + try { + return Tasks.await(nodeClient.getConnectedNodes(), TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (Throwable unavailable) { + return new ArrayList(); + } + } + + // --- messages ----------------------------------------------------------- + + public void sendMessage(String path, byte[] payload, int replyToken) { + List nodes = connectedNodes(); + boolean sentToAnyone = false; + for (Node n : nodes) { + if (!n.isNearby()) { + continue; + } + // The peer needs both the CN1 path and, when an answer is wanted, the token to answer + // with. Both ride in the Data Layer path so the payload stays exactly the app's bytes. + String wire = (replyToken == 0 ? MESSAGE_PATH : REQUEST_PATH + replyToken) + encode(path); + messageClient.sendMessage(n.getId(), wire, payload); + sentToAnyone = true; + } + if (!sentToAnyone && replyToken != 0) { + WearableConnection.deliverReply(replyToken, null, "No nearby device is running the app"); + } + } + + public void sendReply(int replyToken, byte[] payload) { + for (Node n : connectedNodes()) { + if (n.isNearby()) { + messageClient.sendMessage(n.getId(), REPLY_PATH + replyToken, payload); + } + } + } + + // --- replicated data ---------------------------------------------------- + + public void putData(String path, byte[] payload) { + PutDataRequest req = PutDataRequest.create(dataPath(path)); + req.setData(payload == null ? new byte[0] : payload); + // Urgent: without it the system may sit on the change for minutes, which reads as "my watch + // never updated" even though the API did its job. + dataClient.putDataItem(req.setUrgent()); + } + + public byte[] getData(String path) { + try { + Uri uri = new Uri.Builder().scheme("wear").path(dataPath(path)).build(); + DataItemBuffer items = Tasks.await(dataClient.getDataItems(uri), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + try { + if (items.getCount() == 0) { + return null; + } + return items.get(0).getData(); + } finally { + items.release(); + } + } catch (Throwable unavailable) { + return null; + } + } + + public void removeData(String path) { + Uri uri = new Uri.Builder().scheme("wear").path(dataPath(path)).build(); + dataClient.deleteDataItems(uri); + } + + public String[] getDataPaths() { + try { + DataItemBuffer items = Tasks.await(dataClient.getDataItems(), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + try { + List out = new ArrayList(); + for (DataItem item : items) { + String p = item.getUri().getPath(); + if (p != null && p.startsWith(PATH_PREFIX)) { + out.add(decode(p.substring(PATH_PREFIX.length()))); + } + } + return out.toArray(new String[out.size()]); + } finally { + items.release(); + } + } catch (Throwable unavailable) { + return new String[0]; + } + } + + public void transferFile(String path, String name, byte[] contents) { + // A DataItem already syncs in the background and survives both apps being killed, which is + // the guarantee a file transfer makes. Naming it under the path keeps several files from + // overwriting each other. + putData(path + "/" + (name == null ? "file" : name), contents); + } + + // --- paths -------------------------------------------------------------- + + static String dataPath(String path) { + return PATH_PREFIX + encode(path); + } + + /** + * Data Layer paths allow a restricted character set and are matched by prefix, so a Codename One + * path is percent-escaped into it and unescaped on the way back. + */ + static String encode(String path) { + if (path == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '/' || c == '-' || c == '_') { + sb.append(c); + } else { + sb.append('%').append(Integer.toHexString(0x10000 | c).substring(1)); + } + } + return sb.toString(); + } + + static String decode(String path) { + if (path == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if (c == '%' && i + 4 < path.length()) { + sb.append((char) Integer.parseInt(path.substring(i + 1, i + 5), 16)); + i += 4; + } else { + sb.append(c); + } + } + return sb.toString(); + } + + /** The wire path prefixes, shared with the listener service. */ + static String messagePath() { + return MESSAGE_PATH; + } + + static String requestPath() { + return REQUEST_PATH; + } + + static String replyPath() { + return REPLY_PATH; + } + + static String pathPrefix() { + return PATH_PREFIX; + } +} 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 new file mode 100644 index 00000000000..3043affb051 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableListenerService.java @@ -0,0 +1,110 @@ +/* + * 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; + +import com.codename1.wearable.WearableConnection; + +import com.google.android.gms.wearable.DataEvent; +import com.google.android.gms.wearable.DataEventBuffer; +import com.google.android.gms.wearable.MessageEvent; +import com.google.android.gms.wearable.WearableListenerService; + +/** + * Receives Wearable Data Layer traffic and hands it to {@code com.codename1.wearable}. Injected into + * the generated project alongside {@link CN1WearableBridge} only when the app references the + * wearable API. + * + *

Android starts this service to deliver a message even when the app is not running, which is + * exactly the case the Codename One API's cold-start queue exists for: everything here forwards + * straight to {@code WearableConnection}, which parks the delivery until the app registers a + * listener and then replays it on the EDT. + */ +public class CN1WearableListenerService extends WearableListenerService { + + @Override + public void onMessageReceived(MessageEvent event) { + String path = event.getPath(); + if (path == null) { + return; + } + if (path.startsWith(CN1WearableBridge.replyPath())) { + // An answer to a request we sent. The token rides in the path. + String token = path.substring(CN1WearableBridge.replyPath().length()); + try { + WearableConnection.deliverReply(Integer.parseInt(token), event.getData(), null); + } catch (NumberFormatException malformed) { + // Not ours, or a peer running a different build. + } + return; + } + if (path.startsWith(CN1WearableBridge.requestPath())) { + // A message that wants an answer. The token and the CN1 path are both in the wire path: + // /cn1/request// + String rest = path.substring(CN1WearableBridge.requestPath().length()); + int slash = rest.indexOf('/'); + if (slash < 0) { + return; + } + try { + int token = Integer.parseInt(rest.substring(0, slash)); + WearableConnection.deliverMessage( + CN1WearableBridge.decode(rest.substring(slash)), event.getData(), token); + } catch (NumberFormatException malformed) { + // Not ours. + } + return; + } + if (path.startsWith(CN1WearableBridge.messagePath())) { + WearableConnection.deliverMessage( + CN1WearableBridge.decode(path.substring(CN1WearableBridge.messagePath().length())), + event.getData(), 0); + } + } + + @Override + public void onDataChanged(DataEventBuffer events) { + for (DataEvent event : events) { + String path = event.getDataItem().getUri().getPath(); + if (path == null || !path.startsWith(CN1WearableBridge.pathPrefix())) { + continue; + } + String appPath = CN1WearableBridge.decode( + path.substring(CN1WearableBridge.pathPrefix().length())); + if (event.getType() == DataEvent.TYPE_DELETED) { + WearableConnection.deliverDataRemoved(appPath); + } else { + WearableConnection.deliverDataChanged(appPath, event.getDataItem().getData()); + } + } + } + + @Override + public void onPeerConnected(com.google.android.gms.wearable.Node peer) { + WearableConnection.notifyStateChanged(); + } + + @Override + public void onPeerDisconnected(com.google.android.gms.wearable.Node peer) { + WearableConnection.notifyStateChanged(); + } +} From 2398d098393085f5c51c2d68cb42e155377e24dc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:55:34 +0300 Subject: [PATCH 005/250] Complications as surfaces watch families A watch complication is a WidgetKit widget in an accessory family, and a Wear complication is the same shape again: content-driven, rendered while the app is not running, fed by a timeline. That is exactly what com.codename1.surfaces already models, so complications are four new WidgetSize families rather than a second API with its own serialization, image handling and state model. WATCH_CIRCULAR / WATCH_RECTANGULAR / WATCH_INLINE / WATCH_CORNER map onto the WidgetKit accessory families, and the Swift renderer resolves the most specific published layout: accessoryRectangular prefers "watchRectangular" and falls back to "lockscreen", so an app that only published a lock-screen layout still gets a complication, and one that designed for both gets what it designed. accessoryCorner is emitted behind an os(watchOS) guard -- the symbol does not exist on iOS, so naming it unguarded would fail to compile the phone extension over code that could never run. WidgetTimeline kept one field per family and a switch in three accessors, which did not survive four more families; it is now a map keyed by family, and the serializer's content check iterates the enum instead of naming members. Both changes mean the next family costs one enum constant. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/surfaces/SurfaceSerializer.java | 18 ++- .../com/codename1/surfaces/WidgetSize.java | 65 ++++++++++- .../codename1/surfaces/WidgetTimeline.java | 60 ++-------- .../util/IOSWidgetExtensionBuilder.java | 69 +++++++++++- .../surfaces/ios/CN1DescriptorWidget.swift | 42 +++++-- .../IOSWidgetExtensionWatchFamilyTest.java | 103 ++++++++++++++++++ 6 files changed, 286 insertions(+), 71 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWidgetExtensionWatchFamilyTest.java diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceSerializer.java b/CodenameOne/src/com/codename1/surfaces/SurfaceSerializer.java index 54b7044c27a..80f8930c6e3 100644 --- a/CodenameOne/src/com/codename1/surfaces/SurfaceSerializer.java +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceSerializer.java @@ -50,6 +50,18 @@ public final class SurfaceSerializer { private SurfaceSerializer() { } + /// True when the timeline carries a layout for at least one size family. Iterating the enum + /// rather than naming families keeps this correct as the catalog grows -- the watch + /// complication families joined it without touching this method. + private static boolean hasAnyExplicitContent(WidgetTimeline timeline) { + for (WidgetSize size : WidgetSize.values()) { + if (timeline.getExplicitContent(size) != null) { + return true; + } + } + return false; + } + /// Serializes a widget timeline. /// /// #### Parameters @@ -63,11 +75,7 @@ private SurfaceSerializer() { /// the timeline JSON public static String serializeTimeline(String kindId, WidgetTimeline timeline, Map imagesOut) { - if (timeline.getDefaultContent() == null - && timeline.getContent(WidgetSize.SMALL) == null - && timeline.getContent(WidgetSize.MEDIUM) == null - && timeline.getContent(WidgetSize.LARGE) == null - && timeline.getContent(WidgetSize.LOCKSCREEN) == null) { + if (timeline.getDefaultContent() == null && !hasAnyExplicitContent(timeline)) { throw new IllegalArgumentException("A widget timeline needs content: call " + "setContent(...) before publishing"); } diff --git a/CodenameOne/src/com/codename1/surfaces/WidgetSize.java b/CodenameOne/src/com/codename1/surfaces/WidgetSize.java index d4f3a8fa288..81ae25e2e99 100644 --- a/CodenameOne/src/com/codename1/surfaces/WidgetSize.java +++ b/CodenameOne/src/com/codename1/surfaces/WidgetSize.java @@ -22,15 +22,41 @@ */ package com.codename1.surfaces; -/// The size families a widget kind supports. iOS maps these to the WidgetKit families -/// (`systemSmall` / `systemMedium` / `systemLarge` and `accessoryRectangular` for `LOCKSCREEN`); -/// Android and desktop treat them as size hints. `LOCKSCREEN` is ignored on Android in this -/// version. +/// The size families a widget kind supports. +/// +/// The first four are the phone families: iOS maps them to the WidgetKit families +/// (`systemSmall` / `systemMedium` / `systemLarge`, and `accessoryRectangular` for `LOCKSCREEN`); +/// Android and desktop treat them as size hints, and `LOCKSCREEN` is ignored on Android. +/// +/// The `WATCH_*` families are **complications** -- the small live readouts on a watch face. They +/// live here rather than in an API of their own because they are the same concept as a widget: +/// content-driven, rendered while your app is not running, and fed by the same [WidgetTimeline]. On +/// Apple a complication is literally a WidgetKit widget in an accessory family; on Wear OS the +/// simple families become complication data and the richer ones become a Tile. +/// +/// Design them for a glance. A complication is a few dozen pixels someone reads in under a second, +/// so a `SurfaceVector` gauge or a single number beats any layout that has to be read. public enum WidgetSize { + /// Small square home-screen widget. iOS `systemSmall`. SMALL("small"), + /// Medium home-screen widget. iOS `systemMedium`. MEDIUM("medium"), + /// Large home-screen widget. iOS `systemLarge`. LARGE("large"), - LOCKSCREEN("lockscreen"); + /// Lock-screen widget. iOS `accessoryRectangular`. + LOCKSCREEN("lockscreen"), + /// Round complication -- the corner or centre slots of a watch face. iOS `accessoryCircular`; + /// Wear OS `RANGED_VALUE` or `MONOCHROMATIC_IMAGE`. Room for a gauge or one glyph. + WATCH_CIRCULAR("watchCircular"), + /// Wide complication, a band across the watch face. iOS `accessoryRectangular`; Wear OS + /// `LONG_TEXT`, or a Tile when the layout is richer than text. The roomiest family. + WATCH_RECTANGULAR("watchRectangular"), + /// One line of text alongside the time. iOS `accessoryInline`; Wear OS `SHORT_TEXT`. Text only -- + /// anything else is dropped. + WATCH_INLINE("watchInline"), + /// Curved complication hugging the bezel of a round face. iOS `accessoryCorner`; renders as the + /// circular family on Wear OS, which has no corner slot. + WATCH_CORNER("watchCorner"); private final String jsonName; @@ -42,4 +68,33 @@ public enum WidgetSize { public String getJsonName() { return jsonName; } + + /// True for the watch complication families, which are published to a watch face rather than to + /// a home or lock screen. + /// + /// #### Returns + /// + /// true if this is a complication family + public boolean isWatchFamily() { + return this == WATCH_CIRCULAR || this == WATCH_RECTANGULAR + || this == WATCH_INLINE || this == WATCH_CORNER; + } + + /// Resolves a wire-format name back to its family. + /// + /// #### Parameters + /// + /// - `jsonName`: the name produced by [#getJsonName()] + /// + /// #### Returns + /// + /// the matching family, or null when the name is unknown + public static WidgetSize fromJsonName(String jsonName) { + for (WidgetSize s : values()) { + if (s.jsonName.equals(jsonName)) { + return s; + } + } + return null; + } } diff --git a/CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java b/CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java index e59f846a209..026b8c765bc 100644 --- a/CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java +++ b/CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java @@ -73,10 +73,10 @@ public Map getState() { } private SurfaceNode defaultContent; - private SurfaceNode smallContent; - private SurfaceNode mediumContent; - private SurfaceNode largeContent; - private SurfaceNode lockscreenContent; + /// Per-family layout overrides. A map rather than a field per family: the catalog grows (the + /// watch accessory families joined the phone ones) and a switch per accessor did not. + private final Map overrides = + new java.util.EnumMap(WidgetSize.class); private final List entries = new ArrayList(); private int reloadPolicy = RELOAD_AT_END; @@ -105,21 +105,12 @@ public WidgetTimeline setContent(SurfaceNode root) { /// /// this timeline, for chaining public WidgetTimeline setContent(WidgetSize size, SurfaceNode root) { - switch (size) { - case SMALL: - smallContent = root; - break; - case MEDIUM: - mediumContent = root; - break; - case LARGE: - largeContent = root; - break; - case LOCKSCREEN: - lockscreenContent = root; - break; - default: - break; + if (size != null) { + if (root == null) { + overrides.remove(size); + } else { + overrides.put(size, root); + } } return this; } @@ -172,41 +163,14 @@ public WidgetTimeline setReloadPolicy(int policy) { /// /// the layout root, or null when neither an override nor a default was set public SurfaceNode getContent(WidgetSize size) { - SurfaceNode override = null; - switch (size) { - case SMALL: - override = smallContent; - break; - case MEDIUM: - override = mediumContent; - break; - case LARGE: - override = largeContent; - break; - case LOCKSCREEN: - override = lockscreenContent; - break; - default: - break; - } + SurfaceNode override = size == null ? null : overrides.get(size); return override != null ? override : defaultContent; } /// Returns the explicit per-size override, or null when the size family falls back to the /// default content. Used by the serializer so only real overrides are emitted per size. SurfaceNode getExplicitContent(WidgetSize size) { - switch (size) { - case SMALL: - return smallContent; - case MEDIUM: - return mediumContent; - case LARGE: - return largeContent; - case LOCKSCREEN: - return lockscreenContent; - default: - return null; - } + return size == null ? null : overrides.get(size); } /// Returns the layout used for size families without an explicit override, or null. 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 436f098d78c..c47a43285ac 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 @@ -403,7 +403,24 @@ 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"); - sb.append(" families: [").append(familiesSwift(kind)).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); + if (watchOnly.length() == 0) { + sb.append(" families: [").append(shared).append("])\n"); + } else { + sb.append("#if os(watchOS)\n"); + sb.append(" families: [").append(shared); + if (shared.length() > 0) { + sb.append(", "); + } + sb.append(watchOnly).append("])\n"); + sb.append("#else\n"); + sb.append(" families: [").append(shared).append("])\n"); + sb.append("#endif\n"); + } sb.append(" }\n"); sb.append("}\n"); } @@ -414,12 +431,12 @@ private static String structName(Kind kind) { return "CN1Widget_" + kind.getId(); } - private static String familiesSwift(Kind kind) { + private static String familiesSwift(Kind kind, boolean watchTarget) { List families = kind.getIosFamilies(); StringBuilder sb = new StringBuilder(); if (families != null) { for (String family : families) { - String mapped = mapFamily(family); + String mapped = mapFamily(family, watchTarget); if (mapped != null && sb.indexOf(mapped) < 0) { if (sb.length() > 0) { sb.append(", "); @@ -435,7 +452,16 @@ private static String familiesSwift(Kind kind) { return sb.toString(); } - private static String mapFamily(String family) { + /// The families that exist only on watchOS, emitted behind an os(watchOS) guard. + private static String watchOnlyFamiliesSwift(Kind kind) { + List families = kind.getIosFamilies(); + if (families != null && families.contains("watchCorner")) { + return ".accessoryCorner"; + } + return ""; + } + + private static String mapFamily(String family, boolean watchTarget) { // Both the portable names (matching the core WidgetSize wire names) and the // WidgetKit-style spellings are accepted, so manifests written against either // naming in the docs resolve to the same families. @@ -451,10 +477,45 @@ private static String mapFamily(String family) { if ("lockscreen".equals(family) || "accessoryRectangular".equals(family)) { return ".accessoryRectangular"; } + // Watch complications. On Apple a complication is a WidgetKit widget in an accessory + // family, which is why they map here rather than through an API of their own. + // watchRectangular shares .accessoryRectangular with the lock screen -- the Swift renderer + // picks the more specific published layout when both exist. + if ("watchCircular".equals(family)) { + return ".accessoryCircular"; + } + if ("watchRectangular".equals(family)) { + return ".accessoryRectangular"; + } + if ("watchInline".equals(family)) { + return ".accessoryInline"; + } + if ("watchCorner".equals(family)) { + // Emitted separately behind an os(watchOS) guard; see watchOnlyFamiliesSwift. + return null; + } // Unknown family names are skipped so newer manifests degrade gracefully. return null; } + /// 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. + /// + /// @param kind the kind to inspect + /// @return true if the kind offers a complication + public static boolean hasWatchFamily(Kind kind) { + List families = kind.getIosFamilies(); + if (families == null) { + return false; + } + for (String family : families) { + if (family != null && family.startsWith("watch")) { + return true; + } + } + return false; + } + private static void plistKeyString(StringBuilder sb, String key, String value) { sb.append(" ").append(escapeXml(key)).append("\n"); sb.append(" ").append(escapeXml(value)).append("\n"); 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 72a4c257e00..c547e9a808b 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 @@ -41,22 +41,46 @@ struct CN1WidgetEntryView: View { } } +/// Maps a WidgetKit family onto the Codename One size families, most specific first. +/// +/// The accessory families are shared between the iOS lock screen and the watch face, so +/// accessoryRectangular resolves to the watch layout when one was published and falls back to the +/// 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]? { - let key: String + var keys: [String] switch family { case .systemSmall: - key = "small" + keys = ["small"] case .systemMedium: - key = "medium" + keys = ["medium"] case .systemLarge, .systemExtraLarge: - key = "large" - case .accessoryRectangular: - key = "lockscreen" + keys = ["large"] default: - key = "default" + keys = [] } - if let layout = layouts[key] as? [String: Any] { - return layout + if #available(iOS 16.0, watchOS 9.0, *) { + switch family { + case .accessoryCircular: + keys = ["watchCircular"] + case .accessoryRectangular: + keys = ["watchRectangular", "lockscreen"] + case .accessoryInline: + keys = ["watchInline"] + default: + break + } +#if os(watchOS) + if family == .accessoryCorner { + // No corner slot outside watchOS; the circular layout is the closest shape. + keys = ["watchCorner", "watchCircular"] + } +#endif + } + for key in keys { + if let layout = layouts[key] as? [String: Any] { + return layout + } } return layouts["default"] as? [String: Any] } 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 new file mode 100644 index 00000000000..e75c8d84c9b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWidgetExtensionWatchFamilyTest.java @@ -0,0 +1,103 @@ +/* + * 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.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// A watch complication is a WidgetKit widget in an accessory family, so the surfaces watch families +/// have to reach the generated widget bundle as those families. The awkward one is +/// `.accessoryCorner`: it exists only on watchOS, so naming it unguarded would fail to compile the +/// iOS extension even though that code would never run. +class IOSWidgetExtensionWatchFamilyTest { + + @Test + void watchFamiliesBecomeAccessoryFamilies() throws IOException { + String bundle = bundleFor("watchCircular", "watchRectangular", "watchInline"); + + assertTrue(bundle.contains(".accessoryCircular")); + assertTrue(bundle.contains(".accessoryRectangular")); + assertTrue(bundle.contains(".accessoryInline")); + assertFalse(bundle.contains("#if os(watchOS)"), + "No watch-only family was declared, so no platform guard is needed"); + } + + @Test + void cornerFamilyIsGuardedToWatchOS() throws IOException { + String bundle = bundleFor("watchCircular", "watchCorner"); + + assertTrue(bundle.contains("#if os(watchOS)"), + "accessoryCorner exists only on watchOS and must be declared behind a guard"); + assertTrue(bundle.contains(".accessoryCorner")); + // The #else arm keeps the iOS extension compiling with the families it does have. + assertTrue(bundle.contains("#else")); + assertTrue(bundle.contains("#endif")); + } + + @Test + void phoneOnlyKindIsUnaffected() throws IOException { + String bundle = bundleFor("small", "medium", "large"); + + assertTrue(bundle.contains(".systemSmall, .systemMedium, .systemLarge")); + assertFalse(bundle.contains("accessory"), + "A kind that declares no watch family must not gain one"); + assertFalse(bundle.contains("#if os(watchOS)")); + } + + @Test + void watchFamilyDetectionDrivesTheWatchExtension() { + assertTrue(IOSWidgetExtensionBuilder.hasWatchFamily( + new IOSWidgetExtensionBuilder.Kind("steps") + .setIosFamilies(Arrays.asList("small", "watchCircular")))); + assertFalse(IOSWidgetExtensionBuilder.hasWatchFamily( + new IOSWidgetExtensionBuilder.Kind("steps") + .setIosFamilies(Arrays.asList("small", "medium")))); + } + + // ------------------------------------------------------------------ + // Helper + // ------------------------------------------------------------------ + + private static String bundleFor(String... families) throws IOException { + IOSWidgetExtensionBuilder b = new IOSWidgetExtensionBuilder() + .setHostBundleId("com.mycompany.myapp") + .setAppGroupId("group.com.mycompany.myapp") + .addKind(new IOSWidgetExtensionBuilder.Kind("steps") + .setName("Steps") + .setIosFamilies(Arrays.asList(families))); + Map files = b.buildFileMap(); + for (Map.Entry e : files.entrySet()) { + if (e.getKey().endsWith("CN1WidgetBundle.swift")) { + return new String(e.getValue(), StandardCharsets.UTF_8); + } + } + throw new AssertionError("The generated widget bundle was not produced: " + files.keySet()); + } +} From 2151fc9d0f029d6627bfc8848811e067a247ec61 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:19:33 +0300 Subject: [PATCH 006/250] Rewrite the wearables guide around the two-app model The chapter documented build hints. It now documents the product: how one project produces two apps, what they do and do not share, how to run the pair while you develop, how they exchange information, and how a complication is published. The section that matters most is the data one, because the mistake it prevents is the common one. A watch app and a phone app are two apps in two sandboxes, so Storage, Preferences and the SQLite database are per device -- a value written on the phone is simply not on the watch. The three transports exist because they answer three different questions, and choosing the wrong one is the usual reason a watch app "never gets the update", so the chapter leads with a decision table and says plainly which to reach for by default. Also corrected: the old chapter told developers to iterate on a watch layout in the simulator, which was untrue until this branch made isWatch() work there. The complications section states honestly that the families and descriptor pipeline are in place but the platform targets that render them on a watch face are not generated yet, rather than implying a working feature. Snippets are extracted into docs/demos as the guide requires; Vale, LanguageTool, the capitalization check, snippet validation and the warning-free Asciidoctor build all pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../generated/WearablesJava001Snippet.java | 67 +++++ docs/developer-guide/Wearables.asciidoc | 281 ++++++++++++++---- 2 files changed, 295 insertions(+), 53 deletions(-) diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java index 0cf4bf5fb99..a9f0344154c 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java @@ -15,6 +15,8 @@ import com.codename1.charts.views.*; import com.codename1.capture.*; import com.codename1.io.*; +import com.codename1.surfaces.*; +import com.codename1.wearable.*; import com.codename1.l10n.*; import com.codename1.location.*; import com.codename1.maps.*; @@ -55,6 +57,13 @@ class WearablesJava001Snippet { Label label; BrowserComponent browserComponent; Resources theme; + Label stepsLabel; + int stepCount = 0; + void showWorkout(String id) { + } + String beginWorkout() { + return "w1"; + } void snippet() throws Exception { // tag::wearables-java-001[] Form f = new Form(BoxLayout.y()); @@ -68,5 +77,63 @@ void snippet() throws Exception { } f.show(); // end::wearables-java-001[] + + // tag::wearables-java-002[] + // On the phone: publish the value the watch should show whenever it next wakes. + WearableConnection.putData(new WearableMessage("/steps") + .put("count", stepCount) + .put("goalReached", stepCount >= 10000)); + // end::wearables-java-002[] + + // tag::wearables-java-003[] + // On the watch: react to it. Register from init(), not from a form -- a value that + // arrived while the app was starting is replayed only to listeners that exist by then. + WearableConnection.addDataListener(new WearableDataListener() { + public void dataChanged(WearableMessage data) { + stepsLabel.setText("" + data.getInt("count", 0)); + } + + public void dataRemoved(String path) { + stepsLabel.setText("--"); + } + }); + // end::wearables-java-003[] + + // tag::wearables-java-004[] + // Ask the phone something and use the answer. Only works while both apps are awake, + // so check first and fall back to what you already replicated. + if (WearableConnection.isReachable()) { + WearableConnection.sendMessage(new WearableMessage("/workout/start"), + new WearableReplyHandler() { + public void replyReceived(WearableMessage reply) { + showWorkout(reply.getString("id", null)); + } + + public void replyFailed(String message) { + Log.p("Could not start the workout: " + message); + } + }); + } + // end::wearables-java-004[] + + // tag::wearables-java-005[] + // Answer the watch. Reply quickly and do slow work afterwards -- the sender is waiting. + WearableConnection.addMessageListener(new WearableMessageListener() { + public WearableMessage messageReceived(WearableMessage message, boolean expectsReply) { + if ("/workout/start".equals(message.getPath())) { + return new WearableMessage("/workout/start").put("id", beginWorkout()); + } + return null; + } + }); + // end::wearables-java-005[] + + // tag::wearables-java-006[] + // A complication is a widget in a watch family, published from the same timeline. + WidgetKind steps = new WidgetKind("steps") + .setDisplayName("Steps") + .addSupportedSize(WidgetSize.WATCH_CIRCULAR) + .addSupportedSize(WidgetSize.WATCH_RECTANGULAR); + // end::wearables-java-006[] } } diff --git a/docs/developer-guide/Wearables.asciidoc b/docs/developer-guide/Wearables.asciidoc index 57d5202cf40..b5519a72ecd 100644 --- a/docs/developer-guide/Wearables.asciidoc +++ b/docs/developer-guide/Wearables.asciidoc @@ -1,12 +1,35 @@ == Wearables (Apple Watch and Wear OS) -Codename One can build and run your application UI on smartwatches: Apple Watch -(watchOS) and Android Wear OS. The same Java/Kotlin code base that drives your -phone app drives the watch app -- you write Codename One UI as usual, and the -build pipeline produces the appropriate watch artifact for each platform. +Codename One builds a watch app from the same project as your phone app, on both +Apple Watch and Wear OS. This chapter covers the whole picture: how one project +produces two apps, how you run the pair while you develop, how the two apps +exchange information, and how to put a complication on a watch face. -The two platforms reach the watch through different mechanisms, and -understanding the difference explains why the build hints and the supported +=== One Project, Two Apps + +Declaring a watch lifecycle class next to your phone main class is the entire +opt-in: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/wearables.properties[tag=wearables-properties-001,indent=0] +---- + +There are no wearable build hints. The watch bundle identifier, deployment +target, signing team and display name are all derived from settings your project +already has, and one declaration builds the watch app on both platforms. + +Note the asymmetry: `codename1.mainName` is a simple class name resolved against +`codename1.packageName`, while `codename1.watchMain` is fully qualified. + +What the two apps share is the code base: your classes, your resources, your +theme and your CSS. What they don't share is anything at runtime. They're two +apps, on two devices, in two sandboxes, with separate lifecycles. In particular +`Storage`, `Preferences` and the SQLite database are *per device*: writing on +the phone doesn't make the value appear on the watch. Moving information +between them is what <> is for. + +The two platforms get there by different routes, which is why their supported feature sets differ: * *Wear OS is Android.* A Wear OS app is an ordinary Android app that declares @@ -20,8 +43,38 @@ feature sets differ: The graphics-heavy, GPU-bound and UIKit-peer APIs that have no watchOS equivalent are unavailable on the watch (see <>). -In both cases the build is *additive*: with the watch hints turned off your -phone build is byte-for-byte unchanged. +Without a watch main class the build is byte-for-byte what it was, so adding one +never changes a phone build you already ship. + +=== Companion or Standalone + +By default the watch app is a *companion*: it ships inside the phone app and the +pair installs together. If the watch app is the product and there is no phone app +to pair with, declare it standalone: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/wearables.properties[tag=wearables-properties-002,indent=0] +---- + +A standalone build produces a watch-only product on Apple, and on Android turns +the single APK into the Wear OS app. + +=== Running the Pair While You Develop + +The simulator can run both halves. Choose a watch skin (Apple Watch 41mm or 45mm, +Wear round or Wear square) to develop the watch UI on its own, or pick *Watch -> +Launch Watch App* to start the watch app beside the phone app. + +The watch app runs in its own process rather than in another window of the same +one, because that's what it becomes on a device -- a second app with its own sandbox. +The two processes find each other, so `sendMessage` and `putData` genuinely +round-trip on your desktop and you can develop the conversation between the two +apps without deploying anything. + +TIP: Check your layout against the *Wear round* skin. A round face is where a +design that assumes a rectangle falls apart, and its safe area is inset +accordingly. === Detecting the Watch Form Factor @@ -58,71 +111,169 @@ A watch screen is small and is frequently round. A few practical guidelines: without forking your code (the override layer activates on watch devices the same way platform overrides do elsewhere). -TIP: You can lay out and iterate on a watch UI in the simulator by guarding the -watch layout with `CN.isWatch()` and exercising both branches; the device build -then renders the same code on the real watch. +=== Sharing Data Between the Phone and the Watch +[[wearable-data]] -=== Apple Watch (watchOS) +The two apps share no storage. `Storage`, `Preferences` and the SQLite database +are per device, and there's no container that spans the pair, so a value written +on the phone is simply not on the watch. `com.codename1.wearable` is the channel +between them, and it's the same API on Apple Watch and Wear OS. -The watchOS build adds a second Xcode target to the generated project. It -compiles the shared, translated application sources for the watch architecture -(`arm64_32` on device), renders through the Core Graphics backend, and -- in the -default _companion_ distribution -- embeds the watch app inside your iOS app so -the pair installs together. The watch app is rooted in a generated SwiftUI -`@main` shell that hosts the Codename One frames and forwards Digital Crown and -tap input into the runtime. +The platforms offer three transports because they answer three different +questions. Choosing the wrong one is the usual reason a watch app "never gets the +update": -.Codename One UI rendered on the watchOS simulator via the Core Graphics backend -image::img/wearables/apple-watch-simulator.png[Codename One running on the Apple Watch simulator,scaledwidth=30%] +[cols="2,2,2"] +|=== +|You need |Use |Delivered -==== Enabling the watchOS Build +|An answer, now, while both apps are awake +|`WearableConnection.sendMessage` +|Immediately, or it fails -Declare the watch lifecycle class next to your phone main class in -`codenameone_settings.properties`: +|The peer to end up with the latest value, whenever it next looks +|`WearableConnection.putData` +|Eventually, survives sleep and relaunch -[source,properties] +|To move a file or a large blob +|`WearableConnection.transferFile` +|In the background, possibly much later + +|Data the watch needs with no phone involved at all +|Ordinary `Storage` plus the network +|As usual + +|Something rendered while your app isn't running +|`com.codename1.surfaces` (see <>) +|By the system, from a published timeline +|=== + +A message is a phone call: it only connects if someone picks up. Replicated data +is a noticeboard: you pin the current value at a path, and the peer reads it +whenever it wakes. Reach for data by default and for messages only when you +genuinely need an answer now. + +==== Replicating State + +Publish on one side: + +[source,java] ---- -include::../demos/common/src/main/snippets/developer-guide/wearables.properties[tag=wearables-properties-001,indent=0] +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java[tag=wearables-java-002,indent=0] ---- -That's the whole opt-in. There are no wearable build hints: the watch bundle -identifier, deployment target, signing team and display name are all derived from -the settings your project already has. The watch app is built as part of the -regular iOS build and embedded in the phone app, so the pair installs together. +React on the other: -Note the asymmetry: `codename1.mainName` is a simple class name resolved against -`codename1.packageName`, while `codename1.watchMain` is fully qualified. +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java[tag=wearables-java-003,indent=0] +---- -==== Standalone Watch Apps +Each path holds one value, so this replicates state rather than queueing events: +two rapid updates to the same path may reach the peer as one. That's what makes +it the right default -- the peer always converges on the latest value, however +long it was away. -By default the watch app is a companion: it ships inside the phone app. If the -watch app is the product and there is no phone app to pair with, declare it -standalone: +IMPORTANT: Register listeners from your app's `init()`. The platform starts an +app purely to hand it a payload, so what arrives may well be the thing that +launched you. Codename One queues those deliveries and replays them on the EDT, +but only to listeners that exist by the time it does. -[source,properties] +==== Asking a Question + +When you need an answer rather than a value, send a message and handle the reply: + +[source,java] ---- -include::../demos/common/src/main/snippets/developer-guide/wearables.properties[tag=wearables-properties-002,indent=0] +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java[tag=wearables-java-004,indent=0] ---- -A standalone build produces a watch-only product on Apple, and on Android turns -the single APK into the Wear OS app. +Then answer it on the other side: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java[tag=wearables-java-005,indent=0] +---- + +A reply is never guaranteed: the peer may be asleep, out of range, or running a +version of your app that doesn't know the path. `replyFailed` is the normal +case, not the exceptional one. + +==== Knowing What's There + +`isSupported()` is false where there is nothing to talk to at all, and every call +is then a harmless no-op, so this API needs no platform conditionals around it. +`isPaired()`, `isCompanionAppInstalled()` and `isReachable()` distinguish the +cases worth telling a user about: no watch, a watch without your watch app +installed, and a sleeping watch. Add a `WearableStateListener` +rather than polling. + +=== Complications and Tiles +[[watch-complications]] -==== Wearable Settings +A complication -- the small live readout on a watch face -- is the same idea as a +home-screen widget: content-driven, rendered while your app isn't running, fed +by a timeline. Codename One models it as such, so a complication is a watch +*family* of `com.codename1.surfaces` rather than an API of its own: -[cols="2,1,4"] +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java[tag=wearables-java-006,indent=0] +---- + +Everything you already know about surfaces applies: the same node catalog, the +same `${key}` state interpolation, the same timeline that lets the OS advance +content on its own clock with no app wakeups. `SurfaceVector` is especially at +home here, because most complications are a gauge, a dial or a ring. + +[cols="2,2,2"] |=== -|Setting |Default |Description +|Family |Apple Watch |Wear OS -|`codename1.watchMain` -|_(none)_ -|Fully-qualified watch lifecycle entry class. Declaring it builds the watch app -on both Apple Watch and Wear OS. +|`WATCH_CIRCULAR` +|`accessoryCircular` +|Ranged-value or monochromatic-image complication -|`codename1.watchStandalone` -|`false` -|The watch app ships on its own rather than inside the phone app. +|`WATCH_RECTANGULAR` +|`accessoryRectangular` +|Long-text complication, or a Tile for a richer layout + +|`WATCH_INLINE` +|`accessoryInline` +|Short-text complication. Text only -- anything else is dropped + +|`WATCH_CORNER` +|`accessoryCorner` +|Renders as circular; Wear OS has no corner slot |=== +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. + +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. + +=== Apple Watch (watchOS) + +The watchOS build adds a second Xcode target to the generated project. It +compiles the shared, translated application sources for the watch architecture +(`arm64_32` on device), renders through the Core Graphics backend, and -- in the +default _companion_ distribution -- embeds the watch app inside your iOS app so +the pair installs together. The watch app is rooted in a generated SwiftUI +`@main` shell that hosts the Codename One frames and forwards Digital Crown and +tap input into the runtime. + +.Codename One UI rendered on the watchOS simulator via the Core Graphics backend +image::img/wearables/apple-watch-simulator.png[Codename One running on the Apple Watch simulator,scaledwidth=30%] + ==== Supported and Unsupported APIs on watchOS [[watch-supported-apis]] @@ -177,11 +328,27 @@ include::../demos/common/src/main/snippets/developer-guide/wearables.xml[tag=wea A standalone Wear build also raises the minimum SDK to API 23, the Wear OS 2.0 standalone baseline, if your project requests a lower level. +==== Wear OS Input and Screen Shape + +Two things behave differently on a watch and are handled for you: + +* *Rotary input.* The rotating side button or bezel scrolls the focused + scrollable container, exactly as the Digital Crown does on Apple Watch. It + arrives on its own input source rather than the mouse-wheel axes, and is scaled + by the device's own scroll factor. +* *Round screens.* A circular face reports no display cutout, so a layout drawn + to the full rectangle would have its corners eaten by the bezel. The safe area + is inset to the largest rectangle that fits inside the circle -- about 15% a + side -- so honouring the form's safe-area insets is enough. + TIP: Because a Wear OS app is an ordinary Android app, you can also declare any additional manifest features and permissions with the generic -`android.uses_feature.` and `android.uses_permission.` hints. The -`android.playService.wearable` hint adds the `play-services-wearable` dependency -if you want to call the Wearable Data Layer APIs directly. +`android.uses_feature.` and `android.uses_permission.` hints. + +NOTE: Referencing `com.codename1.wearable` adds the `play-services-wearable` +dependency and the listener service automatically. The +`android.playService.wearable` hint remains for apps that want to call the Data +Layer APIs directly. === Summary @@ -204,6 +371,14 @@ if you want to call the Wearable Data Layer APIs directly. |Runtime detection |`CN.isWatch()` |`CN.isWatch()` + +|Talking to the phone app +|`com.codename1.wearable` over WatchConnectivity +|`com.codename1.wearable` over the Wearable Data Layer + +|Complications +|WidgetKit accessory families +|Complication data source and Tiles |=== The wearable build is additive on both platforms: without a watch main class, From ceb7835f526b1ed55094f4ccba73f99b9e73cdbc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:35:54 +0300 Subject: [PATCH 007/250] Fix two LanguageTool findings in the wearables chapter "wakeups" and the British "honouring" both trip the gate; the guide is US English. Co-Authored-By: Claude Opus 5 (1M context) --- docs/developer-guide/Wearables.asciidoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/developer-guide/Wearables.asciidoc b/docs/developer-guide/Wearables.asciidoc index b5519a72ecd..301b6f378e1 100644 --- a/docs/developer-guide/Wearables.asciidoc +++ b/docs/developer-guide/Wearables.asciidoc @@ -223,7 +223,7 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/g Everything you already know about surfaces applies: the same node catalog, the same `${key}` state interpolation, the same timeline that lets the OS advance -content on its own clock with no app wakeups. `SurfaceVector` is especially at +content on its own clock with no app wake-ups. `SurfaceVector` is especially at home here, because most complications are a gauge, a dial or a ring. [cols="2,2,2"] @@ -339,7 +339,7 @@ Two things behave differently on a watch and are handled for you: * *Round screens.* A circular face reports no display cutout, so a layout drawn to the full rectangle would have its corners eaten by the bezel. The safe area is inset to the largest rectangle that fits inside the circle -- about 15% a - side -- so honouring the form's safe-area insets is enough. + side -- so honoring the form's safe-area insets is enough. TIP: Because a Wear OS app is an ordinary Android app, you can also declare any additional manifest features and permissions with the generic From 3800eccc383b2c1e1ee52c8fc9121b10d308bba8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:41:59 +0300 Subject: [PATCH 008/250] Add the GPLv2 + Classpath Exception header to files this branch touches The copyright gate checks added and modified sources, so editing a file that never had a header brings it into scope. Five files needed one: GenerateWatchSkins (new), the settings tool's main class, the wearables guide snippet, the surfaces Swift renderer resource, and BuildHintSchemaDefaults -- which carried a truncated hybrid header naming Codename One in the copyright line but Oracle in the grant, and matched neither accepted form. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/javase/BuildHintSchemaDefaults.java | 17 ++++++++++++-- .../generated/WearablesJava001Snippet.java | 23 +++++++++++++++++++ .../surfaces/ios/CN1DescriptorWidget.swift | 23 +++++++++++++++++++ .../settings/CodenameOneSettings.java | 23 +++++++++++++++++++ tools/watch-skins/GenerateWatchSkins.java | 23 +++++++++++++++++++ 5 files changed, 107 insertions(+), 2 deletions(-) diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java index 2dcd8d72e80..3af9567ca1f 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java @@ -1,13 +1,26 @@ /* * 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. Oracle designates this + * 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.javase; /** diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java index a9f0344154c..d42877e3385 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WearablesJava001Snippet.java @@ -1,3 +1,26 @@ +/* + * 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.codenameone.developerguide.snippets.generated; import com.codename1.gpu.*; 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 c547e9a808b..db41f145e7d 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 @@ -1,3 +1,26 @@ +/* + * 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. + */ + // Auto-generated by Codename One from the com.codename1.surfaces framework. // Compiled ONLY into the CN1Widgets extension target (iOS 16.1+). Shared entry view + // configuration factory used by the generated per-kind widget structs. diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/CodenameOneSettings.java b/scripts/settings/common/src/main/java/com/codename1/settings/CodenameOneSettings.java index 9989de7d19c..3f7f06a5837 100644 --- a/scripts/settings/common/src/main/java/com/codename1/settings/CodenameOneSettings.java +++ b/scripts/settings/common/src/main/java/com/codename1/settings/CodenameOneSettings.java @@ -1,3 +1,26 @@ +/* + * 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.settings; import com.codename1.components.InteractionDialog; diff --git a/tools/watch-skins/GenerateWatchSkins.java b/tools/watch-skins/GenerateWatchSkins.java index 7f8e09d33a8..d879b3948c3 100644 --- a/tools/watch-skins/GenerateWatchSkins.java +++ b/tools/watch-skins/GenerateWatchSkins.java @@ -1,3 +1,26 @@ +/* + * 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.awt.*; import java.awt.geom.RoundRectangle2D; import java.awt.image.BufferedImage; From 66234f924159245625d2fe0bc7b4cd0c61c51029 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:47:07 +0300 Subject: [PATCH 009/250] Use HashMap rather than EnumMap in WidgetTimeline The Codename One runtime has no java.util.EnumMap, so the Ant build (which compiles core against CLDC11) failed where the Maven build had not. Lookups here are by key, so the ordering an EnumMap would give buys nothing. Co-Authored-By: Claude Opus 5 (1M context) --- CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java b/CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java index 026b8c765bc..81178261914 100644 --- a/CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java +++ b/CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java @@ -74,9 +74,11 @@ public Map getState() { private SurfaceNode defaultContent; /// Per-family layout overrides. A map rather than a field per family: the catalog grows (the - /// watch accessory families joined the phone ones) and a switch per accessor did not. + /// watch accessory families joined the phone ones) and a switch per accessor did not. A plain + /// HashMap rather than an EnumMap -- the Codename One runtime has no EnumMap, and lookups here + /// are by key so the ordering an EnumMap would give buys nothing. private final Map overrides = - new java.util.EnumMap(WidgetSize.class); + new java.util.HashMap(); private final List entries = new ArrayList(); private int reloadPolicy = RELOAD_AT_END; From ab400fda4d54e25890419942265277b90efa2692 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:06:58 +0300 Subject: [PATCH 010/250] Use Integer/Long/Double.valueOf rather than the boxing constructors SpotBugs treats DM_NUMBER_CTOR and DM_FP_NUMBER_CTOR as build-breaking, and valueOf caches small values rather than allocating. Five sites across the wearable API plus the simulator bridge. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/com/codename1/wearable/WearableConnection.java | 4 ++-- CodenameOne/src/com/codename1/wearable/WearableMessage.java | 6 +++--- .../src/com/codename1/impl/javase/JavaSEWearableBridge.java | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/wearable/WearableConnection.java b/CodenameOne/src/com/codename1/wearable/WearableConnection.java index edb3d5c35be..00bf917fbad 100644 --- a/CodenameOne/src/com/codename1/wearable/WearableConnection.java +++ b/CodenameOne/src/com/codename1/wearable/WearableConnection.java @@ -195,7 +195,7 @@ public static void sendMessage(WearableMessage message, WearableReplyHandler rep if (reply != null) { synchronized (pendingReplies) { token = nextReplyToken++; - pendingReplies.put(new Integer(token), reply); + pendingReplies.put(Integer.valueOf(token), reply); } } b.sendMessage(message.getPath(), message.toByteArray(), token); @@ -405,7 +405,7 @@ public void run() { public static void deliverReply(int replyToken, final byte[] payload, final String error) { final WearableReplyHandler handler; synchronized (pendingReplies) { - handler = pendingReplies.remove(new Integer(replyToken)); + handler = pendingReplies.remove(Integer.valueOf(replyToken)); } if (handler == null) { return; diff --git a/CodenameOne/src/com/codename1/wearable/WearableMessage.java b/CodenameOne/src/com/codename1/wearable/WearableMessage.java index 15c98ce9230..a63eae4ce8a 100644 --- a/CodenameOne/src/com/codename1/wearable/WearableMessage.java +++ b/CodenameOne/src/com/codename1/wearable/WearableMessage.java @@ -136,7 +136,7 @@ public WearableMessage put(String key, String value) { /// /// this message, for chaining public WearableMessage put(String key, int value) { - return set(key, new Integer(value)); + return set(key, Integer.valueOf(value)); } /// Adds a long value. @@ -150,7 +150,7 @@ public WearableMessage put(String key, int value) { /// /// this message, for chaining public WearableMessage put(String key, long value) { - return set(key, new Long(value)); + return set(key, Long.valueOf(value)); } /// Adds a double value. @@ -164,7 +164,7 @@ public WearableMessage put(String key, long value) { /// /// this message, for chaining public WearableMessage put(String key, double value) { - return set(key, new Double(value)); + return set(key, Double.valueOf(value)); } /// Adds a boolean value. diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java index fb7ad52905b..4daa65af6d2 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java @@ -169,7 +169,7 @@ public void putData(String path, byte[] payload) { } // Our own write must not come back to us as a peer change. synchronized (seenData) { - seenData.put(f.getName(), new Long(f.lastModified())); + seenData.put(f.getName(), Long.valueOf(f.lastModified())); } } catch (IOException err) { com.codename1.io.Log.p("Wearable simulator: failed to publish " + path + ": " + err); @@ -375,7 +375,7 @@ private void primeSeenData() { synchronized (seenData) { for (File f : files) { if (f.isFile()) { - seenData.put(f.getName(), new Long(f.lastModified())); + seenData.put(f.getName(), Long.valueOf(f.lastModified())); } } } @@ -402,7 +402,7 @@ private void scanData() { continue; } synchronized (seenData) { - seenData.put(f.getName(), new Long(stamp)); + seenData.put(f.getName(), Long.valueOf(stamp)); } try { WearableConnection.deliverDataChanged(decodePath(f.getName()), readFully(f)); From 3014bc15ea1bec0b8fa74d0d8c20b39769072afc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:47:01 +0300 Subject: [PATCH 011/250] Fix the bugs raised in review Every one of these was real. The severe ones first: - CN1WatchConnectivity tested CN1_USE_WATCHCONNECTIVITY without importing the header the builder defines it in, so the guard was always false, the implementation compiled away, and an app that used the API failed to link against a class its own natives call. - deliverReply decoded onto an empty path, which WearableMessage's constructor rejects -- every successful reply threw. Replies now decode onto the request's own path, which is also what a handler wants to see. - The cold-start queue was drained by whichever listener registered first, so an app that added a data listener before a message listener lost the queued message for good. Queued per listener type now. - An app that only listens never created the bridge, so WCSession was never activated and no traffic arrived. Registering a listener now brings it up. - Wear replies were broadcast to every nearby node; tokens are allocated per node and collide, so the wrong request got answered. The listener records who asked. And the rest: the Wear data Uri needed an authority or it matched nothing; isPaired equated pairing with connectivity; reply tokens needed a delimiter for relative paths; transferFile published raw file bytes where the receiver expects a payload; the reply handler was never completed when an async send failed; Play services was awaited for up to five seconds on the EDT; the exported listener service now validates the source node rather than trusting any caller; the round-screen inset was overwritten by the posted runnable on API 23-27; writeUTF capped strings at 64KiB; the simulator wrote data files in place where a poller could read them half-written, and hid values published while the peer was down; and three blocks and two dictionaries leaked or were used after free under manual reference counting. Also PMD: @Override on the anonymous Runnables, and the encode failure now keeps its cause. Co-Authored-By: Claude Opus 5 (1M context) --- .../wearable/WearableConnection.java | 89 +- .../codename1/wearable/WearableMessage.java | 32 +- .../impl/android/CodenameOneView.java | 1936 +++++++++-------- .../com/codename1/impl/javase/JavaSEPort.java | 7 + .../impl/javase/JavaSEWearableBridge.java | 39 +- .../nativeSources/CN1WatchConnectivity.h | 5 + .../nativeSources/CN1WatchConnectivity.m | 36 +- .../builders/AndroidGradleBuilder.java | 4 + .../builders/wearable/CN1WearableBridge.java | 160 +- .../wearable/CN1WearableListenerService.java | 23 +- 10 files changed, 1299 insertions(+), 1032 deletions(-) diff --git a/CodenameOne/src/com/codename1/wearable/WearableConnection.java b/CodenameOne/src/com/codename1/wearable/WearableConnection.java index 00bf917fbad..860894fd7fc 100644 --- a/CodenameOne/src/com/codename1/wearable/WearableConnection.java +++ b/CodenameOne/src/com/codename1/wearable/WearableConnection.java @@ -61,13 +61,30 @@ public final class WearableConnection { /// Payloads that arrived before anyone was listening. The platform can start an app purely to /// hand it a message, so dropping these would lose exactly the payload that mattered most. - private static final List pendingDeliveries = new ArrayList(); + /// + /// Queued separately per listener type: an app that registers its data listener first would + /// otherwise drain a queued *message* while messageListeners was still empty, losing it for + /// good. + private static final List pendingMessages = new ArrayList(); + private static final List pendingData = new ArrayList(); - /// Reply handlers for outstanding requests, keyed by the token handed to the bridge. - private static final Map pendingReplies = - new HashMap(); + /// Outstanding requests, keyed by the token handed to the bridge. The request path is kept + /// alongside the handler so the reply decodes onto a real path -- a payload has to have one. + private static final Map pendingReplies = + new HashMap(); private static int nextReplyToken = 1; + /// A request waiting for its answer. + private static final class PendingReply { + final WearableReplyHandler handler; + final String path; + + PendingReply(WearableReplyHandler handler, String path) { + this.handler = handler; + this.path = path; + } + } + private WearableConnection() { } @@ -75,6 +92,18 @@ private static WearableBridge bridge() { return Display.getInstance().getWearableBridge(); } + /// Brings the platform bridge into existence. + /// + /// An app that only listens never calls anything that would otherwise create it, and on Apple + /// the native session is not activated until the bridge is first touched -- so a pure listener + /// would sit waiting for traffic that the platform was never told to deliver. + private static void activate() { + WearableBridge b = bridge(); + if (b != null) { + b.isSupported(); + } + } + // --- state -------------------------------------------------------------- /// Returns true when this device can talk to a counterpart app at all. False on a desktop build, @@ -195,7 +224,8 @@ public static void sendMessage(WearableMessage message, WearableReplyHandler rep if (reply != null) { synchronized (pendingReplies) { token = nextReplyToken++; - pendingReplies.put(Integer.valueOf(token), reply); + pendingReplies.put(Integer.valueOf(token), + new PendingReply(reply, message.getPath())); } } b.sendMessage(message.getPath(), message.toByteArray(), token); @@ -303,7 +333,8 @@ public static void transferFile(String path, String name, byte[] contents) { public static void addMessageListener(WearableMessageListener l) { if (l != null && !messageListeners.contains(l)) { messageListeners.add(l); - drainPending(); + activate(); + drainPending(pendingMessages); } } @@ -325,7 +356,8 @@ public static void removeMessageListener(WearableMessageListener l) { public static void addDataListener(WearableDataListener l) { if (l != null && !dataListeners.contains(l)) { dataListeners.add(l); - drainPending(); + activate(); + drainPending(pendingData); } } @@ -347,6 +379,7 @@ public static void removeDataListener(WearableDataListener l) { public static void addStateListener(WearableStateListener l) { if (l != null && !stateListeners.contains(l)) { stateListeners.add(l); + activate(); } } @@ -372,6 +405,7 @@ public static void removeStateListener(WearableStateListener l) { /// - `replyToken`: a positive token when the peer is waiting for an answer, otherwise 0 public static void deliverMessage(final String path, final byte[] payload, final int replyToken) { deliver(new Runnable() { + @Override public void run() { WearableMessage m = WearableMessage.fromByteArray(path, payload); WearableMessage reply = null; @@ -391,7 +425,7 @@ public void run() { } } } - }, !messageListeners.isEmpty()); + }, !messageListeners.isEmpty(), pendingMessages); } /// Framework/port entry point: hands the peer's answer to the waiting reply handler. Called by @@ -403,19 +437,23 @@ public void run() { /// - `payload`: the encoded reply payload, or null when the request failed /// - `error`: a description of the failure, or null on success public static void deliverReply(int replyToken, final byte[] payload, final String error) { - final WearableReplyHandler handler; + final PendingReply pending; synchronized (pendingReplies) { - handler = pendingReplies.remove(Integer.valueOf(replyToken)); + pending = pendingReplies.remove(Integer.valueOf(replyToken)); } - if (handler == null) { + if (pending == null) { return; } Display.getInstance().callSerially(new Runnable() { + @Override public void run() { if (error != null) { - handler.replyFailed(error); + pending.handler.replyFailed(error); } else { - handler.replyReceived(WearableMessage.fromByteArray("", payload)); + // On the request's own path: a message always has one, and answering on the + // path you asked about is what a handler wants to see. + pending.handler.replyReceived( + WearableMessage.fromByteArray(pending.path, payload)); } } }); @@ -430,6 +468,7 @@ public void run() { /// - `payload`: the encoded new value public static void deliverDataChanged(final String path, final byte[] payload) { deliver(new Runnable() { + @Override public void run() { WearableMessage m = WearableMessage.fromByteArray(path, payload); WearableDataListener[] copy = @@ -438,7 +477,7 @@ public void run() { l.dataChanged(m); } } - }, !dataListeners.isEmpty()); + }, !dataListeners.isEmpty(), pendingData); } /// Framework/port entry point: reports that the peer removed a replicated value. Called by the @@ -449,6 +488,7 @@ public void run() { /// - `path`: the path whose value is gone public static void deliverDataRemoved(final String path) { deliver(new Runnable() { + @Override public void run() { WearableDataListener[] copy = dataListeners.toArray(new WearableDataListener[dataListeners.size()]); @@ -456,7 +496,7 @@ public void run() { l.dataRemoved(path); } } - }, !dataListeners.isEmpty()); + }, !dataListeners.isEmpty(), pendingData); } /// Framework/port entry point: reports that reachability, pairing or peer-app installation @@ -464,6 +504,7 @@ public void run() { /// re-queried by the listener, so a stale notification is worthless. public static void notifyStateChanged() { Display.getInstance().callSerially(new Runnable() { + @Override public void run() { WearableStateListener[] copy = stateListeners.toArray(new WearableStateListener[stateListeners.size()]); @@ -479,24 +520,25 @@ public void run() { /// The platform starts an app to hand it a payload, so the payload routinely arrives before the /// app has finished wiring itself up. Parking rather than dropping is what makes it safe to /// register listeners in `init()`. - private static void deliver(Runnable delivery, boolean hasListener) { + private static void deliver(Runnable delivery, boolean hasListener, List queue) { if (!hasListener) { - synchronized (pendingDeliveries) { - pendingDeliveries.add(delivery); + synchronized (queue) { + queue.add(delivery); } return; } Display.getInstance().callSerially(delivery); } - private static void drainPending() { + /// Replays what was queued for one listener type, once a listener of that type exists. + private static void drainPending(List queue) { List drained; - synchronized (pendingDeliveries) { - if (pendingDeliveries.isEmpty()) { + synchronized (queue) { + if (queue.isEmpty()) { return; } - drained = new ArrayList(pendingDeliveries); - pendingDeliveries.clear(); + drained = new ArrayList(queue); + queue.clear(); } for (Runnable r : drained) { Display.getInstance().callSerially(r); @@ -505,6 +547,7 @@ private static void drainPending() { private static void failReply(final WearableReplyHandler reply, final String message) { Display.getInstance().callSerially(new Runnable() { + @Override public void run() { reply.replyFailed(message); } diff --git a/CodenameOne/src/com/codename1/wearable/WearableMessage.java b/CodenameOne/src/com/codename1/wearable/WearableMessage.java index a63eae4ce8a..0c3d75ce859 100644 --- a/CodenameOne/src/com/codename1/wearable/WearableMessage.java +++ b/CodenameOne/src/com/codename1/wearable/WearableMessage.java @@ -300,6 +300,25 @@ public byte[] getBytes(String key, byte[] defaultValue) { return o instanceof byte[] ? (byte[]) o : defaultValue; } + + /// Writes a string as a 32-bit length followed by its UTF-8 bytes. + /// + /// Not `DataOutputStream.writeUTF`: that caps a string at 65,535 encoded bytes and throws + /// beyond it. Nothing in the public API says a value has to be short, and a payload that + /// silently fails to encode because a string grew is a poor way to find out. + private static void writeLongUTF(DataOutputStream out, String value) throws IOException { + byte[] utf8 = value.getBytes("UTF-8"); + out.writeInt(utf8.length); + out.write(utf8); + } + + /// Reads a string written by [#writeLongUTF(DataOutputStream,String)]. + private static String readLongUTF(DataInputStream in) throws IOException { + byte[] utf8 = new byte[in.readInt()]; + in.readFully(utf8); + return new String(utf8, "UTF-8"); + } + // --- wire format -------------------------------------------------------- /// Serializes the payload to the compact form the platform bridges carry. Application code does @@ -315,11 +334,11 @@ public byte[] toByteArray() { out.writeByte(FORMAT_VERSION); out.writeShort(values.size()); for (Map.Entry e : values.entrySet()) { - out.writeUTF(e.getKey()); + writeLongUTF(out, e.getKey()); Object v = e.getValue(); if (v instanceof String) { out.writeByte(TYPE_STRING); - out.writeUTF((String) v); + writeLongUTF(out, (String) v); } else if (v instanceof Integer) { out.writeByte(TYPE_INT); out.writeInt(((Integer) v).intValue()); @@ -343,7 +362,10 @@ public byte[] toByteArray() { } catch (IOException err) { // A ByteArrayOutputStream cannot fail; rethrowing keeps callers honest // if that ever stops being true. - throw new IllegalStateException("Failed to encode wearable payload: " + err); + IllegalStateException wrapped = + new IllegalStateException("Failed to encode wearable payload: " + err); + wrapped.initCause(err); + throw wrapped; } return bo.toByteArray(); } @@ -378,11 +400,11 @@ public static WearableMessage fromByteArray(String path, byte[] data) { } int count = in.readShort(); for (int i = 0; i < count; i++) { - String key = in.readUTF(); + String key = readLongUTF(in); int type = in.readByte(); switch (type) { case TYPE_STRING: - m.put(key, in.readUTF()); + m.put(key, readLongUTF(in)); break; case TYPE_INT: m.put(key, in.readInt()); diff --git a/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java b/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java index fb606218f9d..819f5135de5 100644 --- a/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java +++ b/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java @@ -1,966 +1,970 @@ -/* - * Copyright (c) 2012, 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; - -import android.app.Activity; -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Rect; -import android.util.Log; -import android.view.*; -import android.view.inputmethod.EditorInfo; -import android.os.Build; -import com.codename1.ui.Component; -import com.codename1.ui.Display; -import com.codename1.ui.Form; -import com.codename1.ui.PeerComponent; -import com.codename1.ui.Sheet; -import com.codename1.ui.TextArea; -import com.codename1.ui.events.ActionEvent; -import com.codename1.ui.events.ActionListener; -import java.lang.reflect.Method; - - -/** - * - * @author Chen - */ -public class CodenameOneView { - - int width = 1; - int height = 1; - Bitmap bitmap; - AndroidGraphics buffy = null; - private Canvas canvas; - private AndroidImplementation implementation = null; - private final Rect bounds = new Rect(); - private boolean fireKeyDown = false; - //private volatile boolean created = false; - private boolean drawing; - - private final Rect safeArea = new Rect(); - - private static final int VERSION_CODE_P = 28; - private static final int VERSION_CODE_M = 23; - - public CodenameOneView(Activity activity, View androidView, AndroidImplementation implementation, boolean drawing) { - - this.implementation = implementation; - this.drawing = drawing; - androidView.setLayoutParams(new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.FILL_PARENT, - ViewGroup.LayoutParams.FILL_PARENT)); - androidView.setFocusable(true); - androidView.setFocusableInTouchMode(true); - androidView.setEnabled(true); - androidView.setClickable(true); - androidView.setLongClickable(false); - - /** - * tell the system that we do our own caching and it does not need to - * use an extra offscreen bitmap. - */ - if(!drawing) { - androidView.setWillNotCacheDrawing(false); - androidView.setWillNotDraw(true); - this.buffy = new AndroidGraphics(implementation, null, false); - } - - /** - * From the docs: "Change whether this view is one of the set of - * scrollable containers in its window. This will be used to determine - * whether the window can resize or must pan when a soft input area is - * open -- scrollable containers allow the window to use resize mode - * since the container will appropriately shrink. " - */ - androidView.setScrollContainer(true); - - android.view.Display androidDisplay = ((WindowManager) activity.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); - width = androidDisplay.getWidth(); - height = androidDisplay.getHeight(); - View rootView = activity.getWindow().getDecorView(); - rootView.post(new Runnable() { - public void run() { - updateSafeArea(); - } - }); - initBitmaps(width, height); - } - - public boolean isOpaque() { - return true; - } - - public void onSurfaceChanged(final int w, final int h) { - if(!Display.isInitialized()) { - return; - } - Display.getInstance().callSerially(new Runnable() { - - public void run() { - handleSizeChange(w, h); - } - }); - } - - public void onSurfaceCreated() { - this.visibilityChangedTo(true); - } - - public void onSurfaceDestroyed() { - this.visibilityChangedTo(false); - } - - private void initBitmaps(int w, int h) { - if(!drawing) { - this.bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); - this.canvas = new Canvas(this.bitmap); - this.buffy.setCanvas(this.canvas); - } - } - - public void visibilityChangedTo(boolean visible) { - if (this.implementation.getCurrentForm() == null) { - return; - } - if (visible) { - this.implementation.showNotifyPublic(); - // request a full repaint as our surfaceview is most likely - // black if this app comes back from the background. - this.implementation.getCurrentForm().repaint(); - } else { - this.implementation.hideNotifyPublic(); - } - } - - private void updateSafeArea() { - final Activity activity = CodenameOneView.this.implementation.getActivity(); - final Rect rect = this.safeArea; - final View rootView = activity.getWindow().getDecorView(); - if (Build.VERSION.SDK_INT >= VERSION_CODE_P) { - try { - Method getRootWindowInsetsMethod = View.class.getMethod("getRootWindowInsets"); - Object insets = getRootWindowInsetsMethod.invoke(rootView); - if (insets != null) { - Class windowInsetsClass = Class.forName("android.view.WindowInsets"); - Method getDisplayCutoutMethod = windowInsetsClass.getMethod("getDisplayCutout"); - Object cutout = getDisplayCutoutMethod.invoke(insets); - - int left = 0; - int top = 0; - int right = 0; - int bottom = 0; - if (cutout != null) { - Class displayCutoutClass = Class.forName("android.view.DisplayCutout"); - Method getSafeInsetLeft = displayCutoutClass.getMethod("getSafeInsetLeft"); - Method getSafeInsetTop = displayCutoutClass.getMethod("getSafeInsetTop"); - Method getSafeInsetRight = displayCutoutClass.getMethod("getSafeInsetRight"); - Method getSafeInsetBottom = displayCutoutClass.getMethod("getSafeInsetBottom"); - left = ((Integer) getSafeInsetLeft.invoke(cutout)).intValue(); - top = ((Integer) getSafeInsetTop.invoke(cutout)).intValue(); - right = ((Integer) getSafeInsetRight.invoke(cutout)).intValue(); - bottom = ((Integer) getSafeInsetBottom.invoke(cutout)).intValue(); - } - - boolean imeVisible = false; - try { - Method isVisibleMethod = insets.getClass().getMethod("isVisible", int.class); - Class typeClass = Class.forName("android.view.WindowInsets$Type"); - int imeType = ((Integer) typeClass.getMethod("ime").invoke(null)).intValue(); - imeVisible = (Boolean) isVisibleMethod.invoke(insets, imeType); - } catch (Throwable t) { - // Fallback or log - } - - Rect systemBarInsets = AndroidImplementation.getSystemBarInsets(rootView); - top = Math.max(systemBarInsets.top, top); - if (imeVisible) { - // Avoid double-counting the bottom gesture bar - bottom = Math.max(bottom, 0); - } else { - bottom = Math.max(systemBarInsets.bottom, bottom); - } - left = Math.max(systemBarInsets.left, left); - right = Math.max(systemBarInsets.right, right); - - if (!AndroidImplementation.isImmersive()) { - top -= systemBarInsets.top; - if (!imeVisible) { - bottom -= systemBarInsets.bottom; - } - left -= systemBarInsets.left; - right -= systemBarInsets.right; - } - - // Only apply if at least one is non-zero - if (left != 0 || top != 0 || right != 0 || bottom != 0) { - boolean isChanged = rect.left != left - || rect.right != right - || rect.top != top - || rect.bottom != bottom; - rect.left = left; - rect.top = top; - rect.right = right; - rect.bottom = bottom; - - if (isChanged) { - Display.getInstance().callSerially(new Runnable() { - public void run() { - AndroidImplementation.getInstance().revalidate(); - } - }); - } - } - } - } catch (Throwable e) { - rect.top = 0; - rect.left = 0; - rect.right = 0; - rect.bottom = 0; - } - - } else if (Build.VERSION.SDK_INT >= VERSION_CODE_M) { - rootView.post(new Runnable() { - public void run() { - WindowInsets insets = rootView.getRootWindowInsets(); - if (insets != null) { - rect.top = insets.getSystemWindowInsetTop(); - rect.left = insets.getSystemWindowInsetLeft();; - rect.right = insets.getSystemWindowInsetRight(); - rect.bottom = insets.getSystemWindowInsetBottom(); - } else { - rect.top = 0; - rect.left = 0; - rect.right = 0; - rect.bottom = 0; - } - } - }); - } else { - // For pre-Marshmallow (API < 23), assume full screen - rect.top = 0; - rect.left = 0; - rect.right = 0; - rect.bottom = 0; - } - applyRoundScreenInset(rect); - } - - /** - * Widens the safe area to clear the curve on a round Wear OS display. - * - * A round watch face reports no display cutout, so everything above leaves the safe area at - * zero and a layout drawn to the full rectangle has its corners cut off by the bezel. The - * largest rectangle that fits inside a circle of diameter d has side d/sqrt(2), so each edge - * loses about 14.6% -- that is what is reserved here, on top of whatever the system already - * asked for. - */ - private void applyRoundScreenInset(Rect rect) { - if (!isRoundScreen()) { - return; - } - int d = Math.min(this.width, this.height); - if (d <= 0) { - return; - } - int inset = (int) Math.ceil(d * (1 - 1 / Math.sqrt(2)) / 2); - rect.left = Math.max(rect.left, inset); - rect.top = Math.max(rect.top, inset); - rect.right = Math.max(rect.right, inset); - rect.bottom = Math.max(rect.bottom, inset); - } - - /** True on a circular watch face, which is most Wear OS hardware. */ - private boolean isRoundScreen() { - try { - return this.implementation.getActivity().getResources() - .getConfiguration().isScreenRound(); - } catch (Throwable preApi23) { - return false; - } - } - - public void handleSizeChange(int w, int h) { - - if(!drawing) { - if ((this.width != w && (this.width < w || this.height < h)) - || (bitmap.getHeight() < h)) { - this.initBitmaps(w, h); - } - } - if (this.width == w && this.height == h) { - return; - } - this.width = w; - this.height = h; - - updateSafeArea(); - - Log.d("Codename One", "sizechanged: " + width + " " + height + " " + this); - if (this.implementation.getCurrentForm() == null) { - /** - * make sure a form has been set before we can send events to the - * EDT. if we send events before the form has been set we might - * deadlock! - */ - return; - } - - if (InPlaceEditView.isEditing()) { - final Form f = this.implementation.getCurrentForm(); - ActionListener sizeChanged = new ActionListener() { - @Override - public void actionPerformed(ActionEvent evt) { - CodenameOneView.this.implementation.getActivity().runOnUiThread(new Runnable() { - - @Override - public void run() { - InPlaceEditView.reLayoutEdit(); - } - }); - f.removeSizeChangedListener(this); - } - }; - f.addSizeChangedListener(sizeChanged); - } - Display.getInstance().sizeChanged(w, h); - } - - //@Override - protected void d(Canvas canvas) { - if(!drawing) { - boolean empty = canvas.getClipBounds(bounds); - if (empty) { - // ?? - canvas.drawBitmap(bitmap, 0, 0, null); - } else { - bounds.intersect(0, 0, width, height); - canvas.drawBitmap(bitmap, bounds, bounds, null); - } - } - } - - /** - * some info from the MIDP docs about keycodes: - * - * "Applications receive keystroke events in which the individual keys are - * named within a space of key codes. Every key for which events are - * reported to MIDP applications is assigned a key code. The key code values - * are unique for each hardware key unless two keys are obvious synonyms for - * each other. MIDP defines the following key codes: KEY_NUM0, KEY_NUM1, - * KEY_NUM2, KEY_NUM3, KEY_NUM4, KEY_NUM5, KEY_NUM6, KEY_NUM7, KEY_NUM8, - * KEY_NUM9, KEY_STAR, and KEY_POUND. (These key codes correspond to keys on - * a ITU-T standard telephone keypad.) Other keys may be present on the - * keyboard, and they will generally have key codes distinct from those list - * above. In order to guarantee portability, applications should use only - * the standard key codes. - * - * The standard key codes' values are equal to the Unicode encoding for the - * character that represents the key. If the device includes any other keys - * that have an obvious correspondence to a Unicode character, their key - * code values should equal the Unicode encoding for that character. For - * keys that have no corresponding Unicode character, the implementation - * must use negative values. Zero is defined to be an invalid key code." - * - * Because the MIDP implementation is our reference and that implementation - * does not interpret the given keycodes we behave alike and pass on the - * unicode values. - */ - final static int internalKeyCodeTranslate(int keyCode) { - /** - * make sure these important keys have a negative value when passed to - * Codename One or they might be interpreted as characters. - */ - switch (keyCode) { - case KeyEvent.KEYCODE_DPAD_DOWN: - return AndroidImplementation.DROID_IMPL_KEY_DOWN; - case KeyEvent.KEYCODE_DPAD_UP: - return AndroidImplementation.DROID_IMPL_KEY_UP; - case KeyEvent.KEYCODE_DPAD_LEFT: - return AndroidImplementation.DROID_IMPL_KEY_LEFT; - case KeyEvent.KEYCODE_DPAD_RIGHT: - return AndroidImplementation.DROID_IMPL_KEY_RIGHT; - case KeyEvent.KEYCODE_DPAD_CENTER: - return AndroidImplementation.DROID_IMPL_KEY_FIRE; - case KeyEvent.KEYCODE_MENU: - return AndroidImplementation.DROID_IMPL_KEY_MENU; - case KeyEvent.KEYCODE_CLEAR: - return AndroidImplementation.DROID_IMPL_KEY_CLEAR; - case KeyEvent.KEYCODE_DEL: - return AndroidImplementation.DROID_IMPL_KEY_BACKSPACE; - case KeyEvent.KEYCODE_BACK: - return AndroidImplementation.DROID_IMPL_KEY_BACK; - case KeyEvent.KEYCODE_ENTER: - case KeyEvent.KEYCODE_NUMPAD_ENTER: - return AndroidImplementation.DROID_IMPL_KEY_ENTER; - case KeyEvent.KEYCODE_TAB: - return AndroidImplementation.DROID_IMPL_KEY_TAB; - case KeyEvent.KEYCODE_ESCAPE: - return AndroidImplementation.DROID_IMPL_KEY_ESCAPE; - case KeyEvent.KEYCODE_MOVE_HOME: - return AndroidImplementation.DROID_IMPL_KEY_HOME; - case KeyEvent.KEYCODE_MOVE_END: - return AndroidImplementation.DROID_IMPL_KEY_END; - case KeyEvent.KEYCODE_PAGE_UP: - return AndroidImplementation.DROID_IMPL_KEY_PAGE_UP; - case KeyEvent.KEYCODE_PAGE_DOWN: - return AndroidImplementation.DROID_IMPL_KEY_PAGE_DOWN; - case KeyEvent.KEYCODE_INSERT: - return AndroidImplementation.DROID_IMPL_KEY_INSERT; - case KeyEvent.KEYCODE_FORWARD_DEL: - return AndroidImplementation.DROID_IMPL_KEY_FORWARD_DEL; - case KeyEvent.KEYCODE_F1: - return AndroidImplementation.DROID_IMPL_KEY_F1; - case KeyEvent.KEYCODE_F2: - return AndroidImplementation.DROID_IMPL_KEY_F2; - case KeyEvent.KEYCODE_F3: - return AndroidImplementation.DROID_IMPL_KEY_F3; - case KeyEvent.KEYCODE_F4: - return AndroidImplementation.DROID_IMPL_KEY_F4; - case KeyEvent.KEYCODE_F5: - return AndroidImplementation.DROID_IMPL_KEY_F5; - case KeyEvent.KEYCODE_F6: - return AndroidImplementation.DROID_IMPL_KEY_F6; - case KeyEvent.KEYCODE_F7: - return AndroidImplementation.DROID_IMPL_KEY_F7; - case KeyEvent.KEYCODE_F8: - return AndroidImplementation.DROID_IMPL_KEY_F8; - case KeyEvent.KEYCODE_F9: - return AndroidImplementation.DROID_IMPL_KEY_F9; - case KeyEvent.KEYCODE_F10: - return AndroidImplementation.DROID_IMPL_KEY_F10; - case KeyEvent.KEYCODE_F11: - return AndroidImplementation.DROID_IMPL_KEY_F11; - case KeyEvent.KEYCODE_F12: - return AndroidImplementation.DROID_IMPL_KEY_F12; - default: - return keyCode; - } - } - - public boolean onKeyUpDown(boolean down, int keyCode, KeyEvent event) { - // Capture the raw Android keycode before translation so we can ask the - // KeyEvent for the unicode mapping (event.getUnicodeChar expects the - // device's native keycode, not our negative sentinels). - final int rawKeyCode = keyCode; - keyCode = internalKeyCodeTranslate(keyCode); - - switch (rawKeyCode) { - case KeyEvent.KEYCODE_VOLUME_DOWN: - case KeyEvent.KEYCODE_VOLUME_UP: - case KeyEvent.KEYCODE_SEARCH: - case KeyEvent.KEYCODE_SHIFT_LEFT: - case KeyEvent.KEYCODE_SHIFT_RIGHT: - case KeyEvent.KEYCODE_ALT_LEFT: - case KeyEvent.KEYCODE_ALT_RIGHT: - case KeyEvent.KEYCODE_CTRL_LEFT: - case KeyEvent.KEYCODE_CTRL_RIGHT: - case KeyEvent.KEYCODE_META_LEFT: - case KeyEvent.KEYCODE_META_RIGHT: - case KeyEvent.KEYCODE_FUNCTION: - case KeyEvent.KEYCODE_CAPS_LOCK: - case KeyEvent.KEYCODE_NUM_LOCK: - case KeyEvent.KEYCODE_SCROLL_LOCK: - case KeyEvent.KEYCODE_SYM: - return false; - default: - } - - if (this.implementation.getCurrentForm() == null) { - /** - * make sure a form has been set before we can send events to the - * EDT. if we send events before the form has been set we might - * deadlock! - */ - return true; - } - - // Hardware (Bluetooth / Chromebook) keys bypass the IME and would otherwise be - // dropped while a pure-editor input session is bound (the editor's raw key path is - // disabled when the platform session is active). Route them through the same - // translation the IME-synthesized keys use. - if (AndroidImplementation.routeHardwareKeyToActiveClient(down, event)) { - return true; - } - - // ENTER is gated for back-compat: on touch keyboards Enter is the IME - // "done" action, so apps historically had to opt in via sendEnterKey. - // Default it on when a hardware (alpha) keyboard generated the event - // so BT/Chromebook keyboards just work. - if (keyCode == AndroidImplementation.DROID_IMPL_KEY_ENTER) { - boolean optIn = Display.getInstance().getProperty("sendEnterKey", "false").equals("true"); - if (!optIn && !isHardwareKeyboardEvent(event)) { - return false; - } - } - - if (event.getRepeatCount() > 0) { - // skip repeats - return true; - } - - if (keyCode == AndroidImplementation.DROID_IMPL_KEY_FIRE) { - this.fireKeyDown = down; - } else if (keyCode == AndroidImplementation.DROID_IMPL_KEY_DOWN - || keyCode == AndroidImplementation.DROID_IMPL_KEY_UP - || keyCode == AndroidImplementation.DROID_IMPL_KEY_LEFT - || keyCode == AndroidImplementation.DROID_IMPL_KEY_RIGHT) { - if (this.fireKeyDown) { - /** - * we keep track of trackball press/release. while it is pressed - * we drop directional movements. these movements are most - * likely not intended. if the device has no trackball i see no - * situation where this additional behavior could hurt. - */ - return true; - } - } - - // Any key our translator mapped to a negative CN1 sentinel is forwarded - // verbatim. The MENU sentinel still defers to the platform when native - // commands are enabled. - if (keyCode < 0) { - if (keyCode == AndroidImplementation.DROID_IMPL_KEY_MENU - && Display.getInstance().getCommandBehavior() == Display.COMMAND_BEHAVIOR_NATIVE) { - return false; - } - if (down) { - Display.getInstance().keyPressed(keyCode); - } else { - Display.getInstance().keyReleased(keyCode); - } - return true; - } - - /** - * Codename One's TextField does not seem to work well if two - * keyup-keydown sequences of different keys are not strictly - * sequential. so we pass the up event of a character right - * after the down event. this is exactly the behavior of the - * BlackBerry implementation from this repository and has worked - * well for me. i guess this should be changed as soon as the - * TextField changes. - */ - // Use the KeyEvent's own device mapping rather than the cached - // BUILT_IN_KEYBOARD map: BT/USB keyboards on Android resolve their - // own layout through KeyEvent.getUnicodeChar, including the full - // meta state (SHIFT/ALT/CTRL/FN/CAPS). - final int nextchar = event.getUnicodeChar(event.getMetaState()); - if (nextchar == 0) { - // Non-printable key we don't translate (e.g. KEYCODE_BREAK, - // media keys). Consume it silently rather than firing keyPressed(0). - return true; - } - if (down) { - Display.getInstance().keyPressed(nextchar); - } else { - Display.getInstance().keyReleased(nextchar); - } - return true; - } - - private static boolean isHardwareKeyboardEvent(KeyEvent event) { - android.view.InputDevice device = event.getDevice(); - if (device != null) { - return device.getKeyboardType() == android.view.KeyCharacterMap.ALPHA; - } - return event.getDeviceId() != android.view.KeyCharacterMap.VIRTUAL_KEYBOARD; - } - - private boolean cn1GrabbedPointer = false; - //private boolean nativePeerGrabbedPointer = false; - - public boolean onTouchEvent(MotionEvent event) { - - if (this.implementation.getCurrentForm() == null) { - /** - * make sure a form has been set before we can send events to the - * EDT. if we send events before the form has been set we might - * deadlock! - */ - return true; - } - if (event.getAction() == MotionEvent.ACTION_UP) { - // EditText re-summons a dismissed keyboard on every tap; give the pure - // editors the same behavior while their input session is bound - AndroidImplementation.showSoftInputForActiveClient(); - } - - - - int[] x = null; - int[] y = null; - int size = event.getPointerCount(); - if (size > 1) { - x = new int[size]; - y = new int[size]; - for (int i = 0; i < size; i++) { - x[i] = (int) event.getX(i); - y[i] = (int) event.getY(i); - } - } - /* - if (!cn1GrabbedPointer) { - - if (x == null) { - Component componentAt = this.implementation.getCurrentForm().getComponentAt((int)event.getX(), (int)event.getY()); - if (componentAt != null && (componentAt instanceof PeerComponent)) { - - if (event.getAction() == MotionEvent.ACTION_DOWN) { - //nativePeerGrabbedPointer = true; - } else if (event.getAction() == MotionEvent.ACTION_UP) { - //nativePeerGrabbedPointer = false; - } - return false; - } - - } else { - Component componentAt = this.implementation.getCurrentForm().getComponentAt((int)x[0], (int)y[0]); - if (componentAt != null && (componentAt instanceof PeerComponent)) { - if (event.getAction() == MotionEvent.ACTION_DOWN) { - nativePeerGrabbedPointer = true; - } else if (event.getAction() == MotionEvent.ACTION_UP) { - nativePeerGrabbedPointer = false; - } - return false; - } - } - } - */ - - //if (nativePeerGrabbedPointer) { - // return false; - //} - Component componentAt; - try { - if (x == null) { - componentAt = this.implementation.getCurrentForm().getComponentAt((int)event.getX(), (int)event.getY()); - } else { - componentAt = this.implementation.getCurrentForm().getComponentAt((int)x[0], (int)y[0]); - } - } catch (Throwable t) { - // Since this is is an EDT violation, we may get an exception - // Just consume it - componentAt = null; - } - boolean isPeer = (componentAt instanceof PeerComponent); - if (isPeer) { - int primaryX = x == null ? (int) event.getX() : x[0]; - int primaryY = y == null ? (int) event.getY() : y[0]; - isPeer = !Sheet.isSheetVisibleAt(primaryX, primaryY); - } - boolean consumeEvent = !isPeer || cn1GrabbedPointer; - - updatePointerMetadata(event, false); - - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - if (x == null) { - this.implementation.pointerPressed((int) event.getX(), (int) event.getY()); - } else { - this.implementation.pointerPressed(x, y); - } - if (!isPeer) cn1GrabbedPointer = true; - break; - case MotionEvent.ACTION_UP: - if (x == null) { - this.implementation.pointerReleased((int) event.getX(), (int) event.getY()); - } else { - this.implementation.pointerReleased(x, y); - } - cn1GrabbedPointer = false; - break; - case MotionEvent.ACTION_CANCEL: - cn1GrabbedPointer = false; - break; - case MotionEvent.ACTION_MOVE: - if (x == null) { - this.implementation.pointerDragged((int) event.getX(), (int) event.getY()); - } else { - this.implementation.pointerDragged(x, y); - } - break; - } - - return consumeEvent; - } - - /** - * Routes Android hover events (mouse / stylus moving over the surface - * without a button pressed) into Codename One's pointerHover pipeline so - * external pointing devices on Android (BT mouse, Chromebook trackpad, - * stylus) drive hover-aware components. - */ - public boolean onHoverEvent(MotionEvent event) { - if (this.implementation.getCurrentForm() == null) { - return false; - } - final int x = (int) event.getX(); - final int y = (int) event.getY(); - updatePointerMetadata(event, true); - switch (event.getActionMasked()) { - case MotionEvent.ACTION_HOVER_ENTER: - this.implementation.pointerHoverPressed(x, y); - return true; - case MotionEvent.ACTION_HOVER_MOVE: - this.implementation.pointerHover(x, y); - return true; - case MotionEvent.ACTION_HOVER_EXIT: - this.implementation.pointerHoverReleased(x, y); - return true; - } - return false; - } - - /** - * Routes Android generic motion events into Codename One. This captures the - * mouse wheel and trackpad scroll axes (vertical and horizontal) from - * external pointing devices (BT mouse, Chromebook trackpad, DeX) which are - * not delivered through onTouchEvent, and the Wear OS rotary input (the - * rotating side button / bezel) which reports on a different axis again. - */ - public boolean onGenericMotionEvent(MotionEvent event) { - if (this.implementation.getCurrentForm() == null) { - return false; - } - if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) { - int x = (int) event.getX(); - int y = (int) event.getY(); - int step = this.implementation.convertToPixels(20, true); - - // Wear OS rotary input arrives from SOURCE_ROTARY_ENCODER on AXIS_SCROLL, not on the - // mouse axes below -- a watch app that only handled those could not scroll at all. It - // is the Digital Crown's counterpart, so it feeds the same wheel path, and Android - // scales it by the device's own scroll factor rather than a fixed step. - if (isRotaryEncoder(event)) { - float rotary = event.getAxisValue(MotionEvent.AXIS_SCROLL); - if (rotary == 0) { - return false; - } - int scrollY = Math.round(-rotary * rotaryScrollFactor(step)); - this.implementation.pointerWheelMoved(x, y, 0, scrollY, true, motionModifierMask(event)); - return true; - } - - float vscroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL); - float hscroll = event.getAxisValue(MotionEvent.AXIS_HSCROLL); - if (vscroll == 0 && hscroll == 0) { - return false; - } - // A positive scrollY reveals content above (drag down); Android reports a - // positive VSCROLL when scrolling away from the user, so negate to match. - int scrollY = Math.round(-vscroll * step); - int scrollX = Math.round(-hscroll * step); - this.implementation.pointerWheelMoved(x, y, scrollX, scrollY, true, motionModifierMask(event)); - return true; - } - return false; - } - - /** - * True when the event came from the Wear OS rotary input. SOURCE_ROTARY_ENCODER and AXIS_SCROLL - * both arrived in API 23, which is also the Wear OS standalone baseline, so older devices - * simply never match. - */ - private static boolean isRotaryEncoder(MotionEvent event) { - if (android.os.Build.VERSION.SDK_INT < 23) { - return false; - } - return (event.getSource() & InputDevice.SOURCE_ROTARY_ENCODER) == InputDevice.SOURCE_ROTARY_ENCODER; - } - - /** - * How many pixels one detent of rotary travel should scroll. Android publishes a per-device - * factor for exactly this; fall back to the shared wheel step when it is unavailable so the - * gesture still does something sensible. - */ - private float rotaryScrollFactor(int fallbackStep) { - try { - float f = ViewConfiguration.get(this.implementation.getActivity()) - .getScaledVerticalScrollFactor(); - if (f > 0) { - return f; - } - } catch (Throwable notAvailable) { - // Pre-API-26 or an unusual device configuration. - } - return fallbackStep; - } - - /** - * Translates the Android MotionEvent tool type, pressure, contact size, tilt - * and button state into the cross-platform pointer metadata so the - * multi-button mouse and stylus APIs work on Android. When hovering is true - * the metadata is flagged as a hover (no contact). - */ - private void updatePointerMetadata(MotionEvent event, boolean hovering) { - int toolType; - try { - toolType = event.getToolType(0); - } catch (Throwable t) { - toolType = MotionEvent.TOOL_TYPE_UNKNOWN; - } - int type; - switch (toolType) { - case MotionEvent.TOOL_TYPE_STYLUS: - type = com.codename1.ui.events.PointerEvent.TYPE_STYLUS; - break; - case MotionEvent.TOOL_TYPE_ERASER: - type = com.codename1.ui.events.PointerEvent.TYPE_ERASER; - break; - case MotionEvent.TOOL_TYPE_MOUSE: - type = com.codename1.ui.events.PointerEvent.TYPE_MOUSE; - break; - case MotionEvent.TOOL_TYPE_FINGER: - type = com.codename1.ui.events.PointerEvent.TYPE_TOUCH; - break; - default: - type = com.codename1.ui.events.PointerEvent.TYPE_UNKNOWN; - break; - } - float pressure = event.getPressure(0); - if (pressure <= 0) { - pressure = 1f; - } - float contactSize = event.getSize(0); - float tiltX = (float) Math.toDegrees(event.getAxisValue(MotionEvent.AXIS_TILT, 0)); - - int buttonState = event.getButtonState(); - int mask = 0; - if ((buttonState & MotionEvent.BUTTON_PRIMARY) != 0) { - mask |= com.codename1.ui.events.PointerEvent.MASK_PRIMARY; - } - if ((buttonState & MotionEvent.BUTTON_SECONDARY) != 0) { - mask |= com.codename1.ui.events.PointerEvent.MASK_SECONDARY; - } - if ((buttonState & MotionEvent.BUTTON_TERTIARY) != 0) { - mask |= com.codename1.ui.events.PointerEvent.MASK_MIDDLE; - } - if ((buttonState & MotionEvent.BUTTON_BACK) != 0) { - mask |= com.codename1.ui.events.PointerEvent.MASK_BACK; - } - if ((buttonState & MotionEvent.BUTTON_FORWARD) != 0) { - mask |= com.codename1.ui.events.PointerEvent.MASK_FORWARD; - } - int button = com.codename1.ui.events.PointerEvent.BUTTON_PRIMARY; - if ((mask & com.codename1.ui.events.PointerEvent.MASK_SECONDARY) != 0) { - button = com.codename1.ui.events.PointerEvent.BUTTON_SECONDARY; - } else if ((mask & com.codename1.ui.events.PointerEvent.MASK_MIDDLE) != 0) { - button = com.codename1.ui.events.PointerEvent.BUTTON_MIDDLE; - } else if ((mask & com.codename1.ui.events.PointerEvent.MASK_BACK) != 0) { - button = com.codename1.ui.events.PointerEvent.BUTTON_BACK; - } else if ((mask & com.codename1.ui.events.PointerEvent.MASK_FORWARD) != 0) { - button = com.codename1.ui.events.PointerEvent.BUTTON_FORWARD; - } else if (mask == 0) { - mask = com.codename1.ui.events.PointerEvent.MASK_PRIMARY; - } - this.implementation.setPointerEventMetadata(button, mask, type, pressure, tiltX, 0, contactSize, - motionModifierMask(event), hovering); - } - - /** - * Builds the cross-platform keyboard modifier mask from an Android MotionEvent meta state. - */ - private int motionModifierMask(MotionEvent event) { - int meta = event.getMetaState(); - int modifiers = 0; - if ((meta & android.view.KeyEvent.META_SHIFT_ON) != 0) { - modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_SHIFT; - } - if ((meta & android.view.KeyEvent.META_CTRL_ON) != 0) { - modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_CONTROL; - } - if ((meta & android.view.KeyEvent.META_ALT_ON) != 0) { - modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_ALT; - } - if ((meta & android.view.KeyEvent.META_META_ON) != 0) { - modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_META; - } - return modifiers; - } - - public AndroidGraphics getGraphics() { - return buffy; - } - - public int getViewHeight() { - return height; - } - - public int getViewWidth() { - return width; - } - - public Rect getSafeArea() { - return safeArea; - } - - public void setInputType(EditorInfo editorInfo) { - - /** - * do not use the enter key to fire some kind of action! - */ -// editorInfo.imeOptions |= EditorInfo.IME_ACTION_NONE; - Component txtCmp = Display.getInstance().getCurrent().getFocused(); - if (txtCmp != null && txtCmp instanceof TextArea) { - TextArea txt = (TextArea) txtCmp; - if (txt.isSingleLineTextArea()) { - editorInfo.imeOptions |= EditorInfo.IME_ACTION_DONE; - - } else { - editorInfo.imeOptions |= EditorInfo.IME_ACTION_NONE; - } - int inputType = 0; - int constraint = txt.getConstraint(); - if ((constraint & TextArea.PASSWORD) == TextArea.PASSWORD) { - constraint = constraint ^ TextArea.PASSWORD; - } - switch (constraint) { - case TextArea.NUMERIC: - inputType = EditorInfo.TYPE_CLASS_NUMBER | EditorInfo.TYPE_NUMBER_FLAG_SIGNED; - break; - case TextArea.DECIMAL: - inputType = EditorInfo.TYPE_CLASS_NUMBER | EditorInfo.TYPE_NUMBER_FLAG_DECIMAL; - break; - case TextArea.PHONENUMBER: - inputType = EditorInfo.TYPE_CLASS_PHONE; - break; - case TextArea.EMAILADDR: - inputType = EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; - break; - case TextArea.URL: - inputType = EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_VARIATION_URI; - break; - default: - inputType = EditorInfo.TYPE_CLASS_TEXT; - break; - - } - - editorInfo.inputType = inputType; - } - } - - -} +/* + * Copyright (c) 2012, 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; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.util.Log; +import android.view.*; +import android.view.inputmethod.EditorInfo; +import android.os.Build; +import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.PeerComponent; +import com.codename1.ui.Sheet; +import com.codename1.ui.TextArea; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import java.lang.reflect.Method; + + +/** + * + * @author Chen + */ +public class CodenameOneView { + + int width = 1; + int height = 1; + Bitmap bitmap; + AndroidGraphics buffy = null; + private Canvas canvas; + private AndroidImplementation implementation = null; + private final Rect bounds = new Rect(); + private boolean fireKeyDown = false; + //private volatile boolean created = false; + private boolean drawing; + + private final Rect safeArea = new Rect(); + + private static final int VERSION_CODE_P = 28; + private static final int VERSION_CODE_M = 23; + + public CodenameOneView(Activity activity, View androidView, AndroidImplementation implementation, boolean drawing) { + + this.implementation = implementation; + this.drawing = drawing; + androidView.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.FILL_PARENT, + ViewGroup.LayoutParams.FILL_PARENT)); + androidView.setFocusable(true); + androidView.setFocusableInTouchMode(true); + androidView.setEnabled(true); + androidView.setClickable(true); + androidView.setLongClickable(false); + + /** + * tell the system that we do our own caching and it does not need to + * use an extra offscreen bitmap. + */ + if(!drawing) { + androidView.setWillNotCacheDrawing(false); + androidView.setWillNotDraw(true); + this.buffy = new AndroidGraphics(implementation, null, false); + } + + /** + * From the docs: "Change whether this view is one of the set of + * scrollable containers in its window. This will be used to determine + * whether the window can resize or must pan when a soft input area is + * open -- scrollable containers allow the window to use resize mode + * since the container will appropriately shrink. " + */ + androidView.setScrollContainer(true); + + android.view.Display androidDisplay = ((WindowManager) activity.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + width = androidDisplay.getWidth(); + height = androidDisplay.getHeight(); + View rootView = activity.getWindow().getDecorView(); + rootView.post(new Runnable() { + public void run() { + updateSafeArea(); + } + }); + initBitmaps(width, height); + } + + public boolean isOpaque() { + return true; + } + + public void onSurfaceChanged(final int w, final int h) { + if(!Display.isInitialized()) { + return; + } + Display.getInstance().callSerially(new Runnable() { + + public void run() { + handleSizeChange(w, h); + } + }); + } + + public void onSurfaceCreated() { + this.visibilityChangedTo(true); + } + + public void onSurfaceDestroyed() { + this.visibilityChangedTo(false); + } + + private void initBitmaps(int w, int h) { + if(!drawing) { + this.bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + this.canvas = new Canvas(this.bitmap); + this.buffy.setCanvas(this.canvas); + } + } + + public void visibilityChangedTo(boolean visible) { + if (this.implementation.getCurrentForm() == null) { + return; + } + if (visible) { + this.implementation.showNotifyPublic(); + // request a full repaint as our surfaceview is most likely + // black if this app comes back from the background. + this.implementation.getCurrentForm().repaint(); + } else { + this.implementation.hideNotifyPublic(); + } + } + + private void updateSafeArea() { + final Activity activity = CodenameOneView.this.implementation.getActivity(); + final Rect rect = this.safeArea; + final View rootView = activity.getWindow().getDecorView(); + if (Build.VERSION.SDK_INT >= VERSION_CODE_P) { + try { + Method getRootWindowInsetsMethod = View.class.getMethod("getRootWindowInsets"); + Object insets = getRootWindowInsetsMethod.invoke(rootView); + if (insets != null) { + Class windowInsetsClass = Class.forName("android.view.WindowInsets"); + Method getDisplayCutoutMethod = windowInsetsClass.getMethod("getDisplayCutout"); + Object cutout = getDisplayCutoutMethod.invoke(insets); + + int left = 0; + int top = 0; + int right = 0; + int bottom = 0; + if (cutout != null) { + Class displayCutoutClass = Class.forName("android.view.DisplayCutout"); + Method getSafeInsetLeft = displayCutoutClass.getMethod("getSafeInsetLeft"); + Method getSafeInsetTop = displayCutoutClass.getMethod("getSafeInsetTop"); + Method getSafeInsetRight = displayCutoutClass.getMethod("getSafeInsetRight"); + Method getSafeInsetBottom = displayCutoutClass.getMethod("getSafeInsetBottom"); + left = ((Integer) getSafeInsetLeft.invoke(cutout)).intValue(); + top = ((Integer) getSafeInsetTop.invoke(cutout)).intValue(); + right = ((Integer) getSafeInsetRight.invoke(cutout)).intValue(); + bottom = ((Integer) getSafeInsetBottom.invoke(cutout)).intValue(); + } + + boolean imeVisible = false; + try { + Method isVisibleMethod = insets.getClass().getMethod("isVisible", int.class); + Class typeClass = Class.forName("android.view.WindowInsets$Type"); + int imeType = ((Integer) typeClass.getMethod("ime").invoke(null)).intValue(); + imeVisible = (Boolean) isVisibleMethod.invoke(insets, imeType); + } catch (Throwable t) { + // Fallback or log + } + + Rect systemBarInsets = AndroidImplementation.getSystemBarInsets(rootView); + top = Math.max(systemBarInsets.top, top); + if (imeVisible) { + // Avoid double-counting the bottom gesture bar + bottom = Math.max(bottom, 0); + } else { + bottom = Math.max(systemBarInsets.bottom, bottom); + } + left = Math.max(systemBarInsets.left, left); + right = Math.max(systemBarInsets.right, right); + + if (!AndroidImplementation.isImmersive()) { + top -= systemBarInsets.top; + if (!imeVisible) { + bottom -= systemBarInsets.bottom; + } + left -= systemBarInsets.left; + right -= systemBarInsets.right; + } + + // Only apply if at least one is non-zero + if (left != 0 || top != 0 || right != 0 || bottom != 0) { + boolean isChanged = rect.left != left + || rect.right != right + || rect.top != top + || rect.bottom != bottom; + rect.left = left; + rect.top = top; + rect.right = right; + rect.bottom = bottom; + + if (isChanged) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + AndroidImplementation.getInstance().revalidate(); + } + }); + } + } + } + } catch (Throwable e) { + rect.top = 0; + rect.left = 0; + rect.right = 0; + rect.bottom = 0; + } + + } else if (Build.VERSION.SDK_INT >= VERSION_CODE_M) { + rootView.post(new Runnable() { + public void run() { + WindowInsets insets = rootView.getRootWindowInsets(); + if (insets != null) { + rect.top = insets.getSystemWindowInsetTop(); + rect.left = insets.getSystemWindowInsetLeft();; + rect.right = insets.getSystemWindowInsetRight(); + rect.bottom = insets.getSystemWindowInsetBottom(); + } else { + rect.top = 0; + rect.left = 0; + rect.right = 0; + rect.bottom = 0; + } + // This branch assigns asynchronously, so the round inset has to be reapplied + // here -- applying it at the end of updateSafeArea would run first and be + // overwritten by the four assignments above. + applyRoundScreenInset(rect); + } + }); + } else { + // For pre-Marshmallow (API < 23), assume full screen + rect.top = 0; + rect.left = 0; + rect.right = 0; + rect.bottom = 0; + } + applyRoundScreenInset(rect); + } + + /** + * Widens the safe area to clear the curve on a round Wear OS display. + * + * A round watch face reports no display cutout, so everything above leaves the safe area at + * zero and a layout drawn to the full rectangle has its corners cut off by the bezel. The + * largest rectangle that fits inside a circle of diameter d has side d/sqrt(2), so each edge + * loses about 14.6% -- that is what is reserved here, on top of whatever the system already + * asked for. + */ + private void applyRoundScreenInset(Rect rect) { + if (!isRoundScreen()) { + return; + } + int d = Math.min(this.width, this.height); + if (d <= 0) { + return; + } + int inset = (int) Math.ceil(d * (1 - 1 / Math.sqrt(2)) / 2); + rect.left = Math.max(rect.left, inset); + rect.top = Math.max(rect.top, inset); + rect.right = Math.max(rect.right, inset); + rect.bottom = Math.max(rect.bottom, inset); + } + + /** True on a circular watch face, which is most Wear OS hardware. */ + private boolean isRoundScreen() { + try { + return this.implementation.getActivity().getResources() + .getConfiguration().isScreenRound(); + } catch (Throwable preApi23) { + return false; + } + } + + public void handleSizeChange(int w, int h) { + + if(!drawing) { + if ((this.width != w && (this.width < w || this.height < h)) + || (bitmap.getHeight() < h)) { + this.initBitmaps(w, h); + } + } + if (this.width == w && this.height == h) { + return; + } + this.width = w; + this.height = h; + + updateSafeArea(); + + Log.d("Codename One", "sizechanged: " + width + " " + height + " " + this); + if (this.implementation.getCurrentForm() == null) { + /** + * make sure a form has been set before we can send events to the + * EDT. if we send events before the form has been set we might + * deadlock! + */ + return; + } + + if (InPlaceEditView.isEditing()) { + final Form f = this.implementation.getCurrentForm(); + ActionListener sizeChanged = new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + CodenameOneView.this.implementation.getActivity().runOnUiThread(new Runnable() { + + @Override + public void run() { + InPlaceEditView.reLayoutEdit(); + } + }); + f.removeSizeChangedListener(this); + } + }; + f.addSizeChangedListener(sizeChanged); + } + Display.getInstance().sizeChanged(w, h); + } + + //@Override + protected void d(Canvas canvas) { + if(!drawing) { + boolean empty = canvas.getClipBounds(bounds); + if (empty) { + // ?? + canvas.drawBitmap(bitmap, 0, 0, null); + } else { + bounds.intersect(0, 0, width, height); + canvas.drawBitmap(bitmap, bounds, bounds, null); + } + } + } + + /** + * some info from the MIDP docs about keycodes: + * + * "Applications receive keystroke events in which the individual keys are + * named within a space of key codes. Every key for which events are + * reported to MIDP applications is assigned a key code. The key code values + * are unique for each hardware key unless two keys are obvious synonyms for + * each other. MIDP defines the following key codes: KEY_NUM0, KEY_NUM1, + * KEY_NUM2, KEY_NUM3, KEY_NUM4, KEY_NUM5, KEY_NUM6, KEY_NUM7, KEY_NUM8, + * KEY_NUM9, KEY_STAR, and KEY_POUND. (These key codes correspond to keys on + * a ITU-T standard telephone keypad.) Other keys may be present on the + * keyboard, and they will generally have key codes distinct from those list + * above. In order to guarantee portability, applications should use only + * the standard key codes. + * + * The standard key codes' values are equal to the Unicode encoding for the + * character that represents the key. If the device includes any other keys + * that have an obvious correspondence to a Unicode character, their key + * code values should equal the Unicode encoding for that character. For + * keys that have no corresponding Unicode character, the implementation + * must use negative values. Zero is defined to be an invalid key code." + * + * Because the MIDP implementation is our reference and that implementation + * does not interpret the given keycodes we behave alike and pass on the + * unicode values. + */ + final static int internalKeyCodeTranslate(int keyCode) { + /** + * make sure these important keys have a negative value when passed to + * Codename One or they might be interpreted as characters. + */ + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_DOWN: + return AndroidImplementation.DROID_IMPL_KEY_DOWN; + case KeyEvent.KEYCODE_DPAD_UP: + return AndroidImplementation.DROID_IMPL_KEY_UP; + case KeyEvent.KEYCODE_DPAD_LEFT: + return AndroidImplementation.DROID_IMPL_KEY_LEFT; + case KeyEvent.KEYCODE_DPAD_RIGHT: + return AndroidImplementation.DROID_IMPL_KEY_RIGHT; + case KeyEvent.KEYCODE_DPAD_CENTER: + return AndroidImplementation.DROID_IMPL_KEY_FIRE; + case KeyEvent.KEYCODE_MENU: + return AndroidImplementation.DROID_IMPL_KEY_MENU; + case KeyEvent.KEYCODE_CLEAR: + return AndroidImplementation.DROID_IMPL_KEY_CLEAR; + case KeyEvent.KEYCODE_DEL: + return AndroidImplementation.DROID_IMPL_KEY_BACKSPACE; + case KeyEvent.KEYCODE_BACK: + return AndroidImplementation.DROID_IMPL_KEY_BACK; + case KeyEvent.KEYCODE_ENTER: + case KeyEvent.KEYCODE_NUMPAD_ENTER: + return AndroidImplementation.DROID_IMPL_KEY_ENTER; + case KeyEvent.KEYCODE_TAB: + return AndroidImplementation.DROID_IMPL_KEY_TAB; + case KeyEvent.KEYCODE_ESCAPE: + return AndroidImplementation.DROID_IMPL_KEY_ESCAPE; + case KeyEvent.KEYCODE_MOVE_HOME: + return AndroidImplementation.DROID_IMPL_KEY_HOME; + case KeyEvent.KEYCODE_MOVE_END: + return AndroidImplementation.DROID_IMPL_KEY_END; + case KeyEvent.KEYCODE_PAGE_UP: + return AndroidImplementation.DROID_IMPL_KEY_PAGE_UP; + case KeyEvent.KEYCODE_PAGE_DOWN: + return AndroidImplementation.DROID_IMPL_KEY_PAGE_DOWN; + case KeyEvent.KEYCODE_INSERT: + return AndroidImplementation.DROID_IMPL_KEY_INSERT; + case KeyEvent.KEYCODE_FORWARD_DEL: + return AndroidImplementation.DROID_IMPL_KEY_FORWARD_DEL; + case KeyEvent.KEYCODE_F1: + return AndroidImplementation.DROID_IMPL_KEY_F1; + case KeyEvent.KEYCODE_F2: + return AndroidImplementation.DROID_IMPL_KEY_F2; + case KeyEvent.KEYCODE_F3: + return AndroidImplementation.DROID_IMPL_KEY_F3; + case KeyEvent.KEYCODE_F4: + return AndroidImplementation.DROID_IMPL_KEY_F4; + case KeyEvent.KEYCODE_F5: + return AndroidImplementation.DROID_IMPL_KEY_F5; + case KeyEvent.KEYCODE_F6: + return AndroidImplementation.DROID_IMPL_KEY_F6; + case KeyEvent.KEYCODE_F7: + return AndroidImplementation.DROID_IMPL_KEY_F7; + case KeyEvent.KEYCODE_F8: + return AndroidImplementation.DROID_IMPL_KEY_F8; + case KeyEvent.KEYCODE_F9: + return AndroidImplementation.DROID_IMPL_KEY_F9; + case KeyEvent.KEYCODE_F10: + return AndroidImplementation.DROID_IMPL_KEY_F10; + case KeyEvent.KEYCODE_F11: + return AndroidImplementation.DROID_IMPL_KEY_F11; + case KeyEvent.KEYCODE_F12: + return AndroidImplementation.DROID_IMPL_KEY_F12; + default: + return keyCode; + } + } + + public boolean onKeyUpDown(boolean down, int keyCode, KeyEvent event) { + // Capture the raw Android keycode before translation so we can ask the + // KeyEvent for the unicode mapping (event.getUnicodeChar expects the + // device's native keycode, not our negative sentinels). + final int rawKeyCode = keyCode; + keyCode = internalKeyCodeTranslate(keyCode); + + switch (rawKeyCode) { + case KeyEvent.KEYCODE_VOLUME_DOWN: + case KeyEvent.KEYCODE_VOLUME_UP: + case KeyEvent.KEYCODE_SEARCH: + case KeyEvent.KEYCODE_SHIFT_LEFT: + case KeyEvent.KEYCODE_SHIFT_RIGHT: + case KeyEvent.KEYCODE_ALT_LEFT: + case KeyEvent.KEYCODE_ALT_RIGHT: + case KeyEvent.KEYCODE_CTRL_LEFT: + case KeyEvent.KEYCODE_CTRL_RIGHT: + case KeyEvent.KEYCODE_META_LEFT: + case KeyEvent.KEYCODE_META_RIGHT: + case KeyEvent.KEYCODE_FUNCTION: + case KeyEvent.KEYCODE_CAPS_LOCK: + case KeyEvent.KEYCODE_NUM_LOCK: + case KeyEvent.KEYCODE_SCROLL_LOCK: + case KeyEvent.KEYCODE_SYM: + return false; + default: + } + + if (this.implementation.getCurrentForm() == null) { + /** + * make sure a form has been set before we can send events to the + * EDT. if we send events before the form has been set we might + * deadlock! + */ + return true; + } + + // Hardware (Bluetooth / Chromebook) keys bypass the IME and would otherwise be + // dropped while a pure-editor input session is bound (the editor's raw key path is + // disabled when the platform session is active). Route them through the same + // translation the IME-synthesized keys use. + if (AndroidImplementation.routeHardwareKeyToActiveClient(down, event)) { + return true; + } + + // ENTER is gated for back-compat: on touch keyboards Enter is the IME + // "done" action, so apps historically had to opt in via sendEnterKey. + // Default it on when a hardware (alpha) keyboard generated the event + // so BT/Chromebook keyboards just work. + if (keyCode == AndroidImplementation.DROID_IMPL_KEY_ENTER) { + boolean optIn = Display.getInstance().getProperty("sendEnterKey", "false").equals("true"); + if (!optIn && !isHardwareKeyboardEvent(event)) { + return false; + } + } + + if (event.getRepeatCount() > 0) { + // skip repeats + return true; + } + + if (keyCode == AndroidImplementation.DROID_IMPL_KEY_FIRE) { + this.fireKeyDown = down; + } else if (keyCode == AndroidImplementation.DROID_IMPL_KEY_DOWN + || keyCode == AndroidImplementation.DROID_IMPL_KEY_UP + || keyCode == AndroidImplementation.DROID_IMPL_KEY_LEFT + || keyCode == AndroidImplementation.DROID_IMPL_KEY_RIGHT) { + if (this.fireKeyDown) { + /** + * we keep track of trackball press/release. while it is pressed + * we drop directional movements. these movements are most + * likely not intended. if the device has no trackball i see no + * situation where this additional behavior could hurt. + */ + return true; + } + } + + // Any key our translator mapped to a negative CN1 sentinel is forwarded + // verbatim. The MENU sentinel still defers to the platform when native + // commands are enabled. + if (keyCode < 0) { + if (keyCode == AndroidImplementation.DROID_IMPL_KEY_MENU + && Display.getInstance().getCommandBehavior() == Display.COMMAND_BEHAVIOR_NATIVE) { + return false; + } + if (down) { + Display.getInstance().keyPressed(keyCode); + } else { + Display.getInstance().keyReleased(keyCode); + } + return true; + } + + /** + * Codename One's TextField does not seem to work well if two + * keyup-keydown sequences of different keys are not strictly + * sequential. so we pass the up event of a character right + * after the down event. this is exactly the behavior of the + * BlackBerry implementation from this repository and has worked + * well for me. i guess this should be changed as soon as the + * TextField changes. + */ + // Use the KeyEvent's own device mapping rather than the cached + // BUILT_IN_KEYBOARD map: BT/USB keyboards on Android resolve their + // own layout through KeyEvent.getUnicodeChar, including the full + // meta state (SHIFT/ALT/CTRL/FN/CAPS). + final int nextchar = event.getUnicodeChar(event.getMetaState()); + if (nextchar == 0) { + // Non-printable key we don't translate (e.g. KEYCODE_BREAK, + // media keys). Consume it silently rather than firing keyPressed(0). + return true; + } + if (down) { + Display.getInstance().keyPressed(nextchar); + } else { + Display.getInstance().keyReleased(nextchar); + } + return true; + } + + private static boolean isHardwareKeyboardEvent(KeyEvent event) { + android.view.InputDevice device = event.getDevice(); + if (device != null) { + return device.getKeyboardType() == android.view.KeyCharacterMap.ALPHA; + } + return event.getDeviceId() != android.view.KeyCharacterMap.VIRTUAL_KEYBOARD; + } + + private boolean cn1GrabbedPointer = false; + //private boolean nativePeerGrabbedPointer = false; + + public boolean onTouchEvent(MotionEvent event) { + + if (this.implementation.getCurrentForm() == null) { + /** + * make sure a form has been set before we can send events to the + * EDT. if we send events before the form has been set we might + * deadlock! + */ + return true; + } + if (event.getAction() == MotionEvent.ACTION_UP) { + // EditText re-summons a dismissed keyboard on every tap; give the pure + // editors the same behavior while their input session is bound + AndroidImplementation.showSoftInputForActiveClient(); + } + + + + int[] x = null; + int[] y = null; + int size = event.getPointerCount(); + if (size > 1) { + x = new int[size]; + y = new int[size]; + for (int i = 0; i < size; i++) { + x[i] = (int) event.getX(i); + y[i] = (int) event.getY(i); + } + } + /* + if (!cn1GrabbedPointer) { + + if (x == null) { + Component componentAt = this.implementation.getCurrentForm().getComponentAt((int)event.getX(), (int)event.getY()); + if (componentAt != null && (componentAt instanceof PeerComponent)) { + + if (event.getAction() == MotionEvent.ACTION_DOWN) { + //nativePeerGrabbedPointer = true; + } else if (event.getAction() == MotionEvent.ACTION_UP) { + //nativePeerGrabbedPointer = false; + } + return false; + } + + } else { + Component componentAt = this.implementation.getCurrentForm().getComponentAt((int)x[0], (int)y[0]); + if (componentAt != null && (componentAt instanceof PeerComponent)) { + if (event.getAction() == MotionEvent.ACTION_DOWN) { + nativePeerGrabbedPointer = true; + } else if (event.getAction() == MotionEvent.ACTION_UP) { + nativePeerGrabbedPointer = false; + } + return false; + } + } + } + */ + + //if (nativePeerGrabbedPointer) { + // return false; + //} + Component componentAt; + try { + if (x == null) { + componentAt = this.implementation.getCurrentForm().getComponentAt((int)event.getX(), (int)event.getY()); + } else { + componentAt = this.implementation.getCurrentForm().getComponentAt((int)x[0], (int)y[0]); + } + } catch (Throwable t) { + // Since this is is an EDT violation, we may get an exception + // Just consume it + componentAt = null; + } + boolean isPeer = (componentAt instanceof PeerComponent); + if (isPeer) { + int primaryX = x == null ? (int) event.getX() : x[0]; + int primaryY = y == null ? (int) event.getY() : y[0]; + isPeer = !Sheet.isSheetVisibleAt(primaryX, primaryY); + } + boolean consumeEvent = !isPeer || cn1GrabbedPointer; + + updatePointerMetadata(event, false); + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + if (x == null) { + this.implementation.pointerPressed((int) event.getX(), (int) event.getY()); + } else { + this.implementation.pointerPressed(x, y); + } + if (!isPeer) cn1GrabbedPointer = true; + break; + case MotionEvent.ACTION_UP: + if (x == null) { + this.implementation.pointerReleased((int) event.getX(), (int) event.getY()); + } else { + this.implementation.pointerReleased(x, y); + } + cn1GrabbedPointer = false; + break; + case MotionEvent.ACTION_CANCEL: + cn1GrabbedPointer = false; + break; + case MotionEvent.ACTION_MOVE: + if (x == null) { + this.implementation.pointerDragged((int) event.getX(), (int) event.getY()); + } else { + this.implementation.pointerDragged(x, y); + } + break; + } + + return consumeEvent; + } + + /** + * Routes Android hover events (mouse / stylus moving over the surface + * without a button pressed) into Codename One's pointerHover pipeline so + * external pointing devices on Android (BT mouse, Chromebook trackpad, + * stylus) drive hover-aware components. + */ + public boolean onHoverEvent(MotionEvent event) { + if (this.implementation.getCurrentForm() == null) { + return false; + } + final int x = (int) event.getX(); + final int y = (int) event.getY(); + updatePointerMetadata(event, true); + switch (event.getActionMasked()) { + case MotionEvent.ACTION_HOVER_ENTER: + this.implementation.pointerHoverPressed(x, y); + return true; + case MotionEvent.ACTION_HOVER_MOVE: + this.implementation.pointerHover(x, y); + return true; + case MotionEvent.ACTION_HOVER_EXIT: + this.implementation.pointerHoverReleased(x, y); + return true; + } + return false; + } + + /** + * Routes Android generic motion events into Codename One. This captures the + * mouse wheel and trackpad scroll axes (vertical and horizontal) from + * external pointing devices (BT mouse, Chromebook trackpad, DeX) which are + * not delivered through onTouchEvent, and the Wear OS rotary input (the + * rotating side button / bezel) which reports on a different axis again. + */ + public boolean onGenericMotionEvent(MotionEvent event) { + if (this.implementation.getCurrentForm() == null) { + return false; + } + if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) { + int x = (int) event.getX(); + int y = (int) event.getY(); + int step = this.implementation.convertToPixels(20, true); + + // Wear OS rotary input arrives from SOURCE_ROTARY_ENCODER on AXIS_SCROLL, not on the + // mouse axes below -- a watch app that only handled those could not scroll at all. It + // is the Digital Crown's counterpart, so it feeds the same wheel path, and Android + // scales it by the device's own scroll factor rather than a fixed step. + if (isRotaryEncoder(event)) { + float rotary = event.getAxisValue(MotionEvent.AXIS_SCROLL); + if (rotary == 0) { + return false; + } + int scrollY = Math.round(-rotary * rotaryScrollFactor(step)); + this.implementation.pointerWheelMoved(x, y, 0, scrollY, true, motionModifierMask(event)); + return true; + } + + float vscroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL); + float hscroll = event.getAxisValue(MotionEvent.AXIS_HSCROLL); + if (vscroll == 0 && hscroll == 0) { + return false; + } + // A positive scrollY reveals content above (drag down); Android reports a + // positive VSCROLL when scrolling away from the user, so negate to match. + int scrollY = Math.round(-vscroll * step); + int scrollX = Math.round(-hscroll * step); + this.implementation.pointerWheelMoved(x, y, scrollX, scrollY, true, motionModifierMask(event)); + return true; + } + return false; + } + + /** + * True when the event came from the Wear OS rotary input. SOURCE_ROTARY_ENCODER and AXIS_SCROLL + * both arrived in API 23, which is also the Wear OS standalone baseline, so older devices + * simply never match. + */ + private static boolean isRotaryEncoder(MotionEvent event) { + if (android.os.Build.VERSION.SDK_INT < 23) { + return false; + } + return (event.getSource() & InputDevice.SOURCE_ROTARY_ENCODER) == InputDevice.SOURCE_ROTARY_ENCODER; + } + + /** + * How many pixels one detent of rotary travel should scroll. Android publishes a per-device + * factor for exactly this; fall back to the shared wheel step when it is unavailable so the + * gesture still does something sensible. + */ + private float rotaryScrollFactor(int fallbackStep) { + try { + float f = ViewConfiguration.get(this.implementation.getActivity()) + .getScaledVerticalScrollFactor(); + if (f > 0) { + return f; + } + } catch (Throwable notAvailable) { + // Pre-API-26 or an unusual device configuration. + } + return fallbackStep; + } + + /** + * Translates the Android MotionEvent tool type, pressure, contact size, tilt + * and button state into the cross-platform pointer metadata so the + * multi-button mouse and stylus APIs work on Android. When hovering is true + * the metadata is flagged as a hover (no contact). + */ + private void updatePointerMetadata(MotionEvent event, boolean hovering) { + int toolType; + try { + toolType = event.getToolType(0); + } catch (Throwable t) { + toolType = MotionEvent.TOOL_TYPE_UNKNOWN; + } + int type; + switch (toolType) { + case MotionEvent.TOOL_TYPE_STYLUS: + type = com.codename1.ui.events.PointerEvent.TYPE_STYLUS; + break; + case MotionEvent.TOOL_TYPE_ERASER: + type = com.codename1.ui.events.PointerEvent.TYPE_ERASER; + break; + case MotionEvent.TOOL_TYPE_MOUSE: + type = com.codename1.ui.events.PointerEvent.TYPE_MOUSE; + break; + case MotionEvent.TOOL_TYPE_FINGER: + type = com.codename1.ui.events.PointerEvent.TYPE_TOUCH; + break; + default: + type = com.codename1.ui.events.PointerEvent.TYPE_UNKNOWN; + break; + } + float pressure = event.getPressure(0); + if (pressure <= 0) { + pressure = 1f; + } + float contactSize = event.getSize(0); + float tiltX = (float) Math.toDegrees(event.getAxisValue(MotionEvent.AXIS_TILT, 0)); + + int buttonState = event.getButtonState(); + int mask = 0; + if ((buttonState & MotionEvent.BUTTON_PRIMARY) != 0) { + mask |= com.codename1.ui.events.PointerEvent.MASK_PRIMARY; + } + if ((buttonState & MotionEvent.BUTTON_SECONDARY) != 0) { + mask |= com.codename1.ui.events.PointerEvent.MASK_SECONDARY; + } + if ((buttonState & MotionEvent.BUTTON_TERTIARY) != 0) { + mask |= com.codename1.ui.events.PointerEvent.MASK_MIDDLE; + } + if ((buttonState & MotionEvent.BUTTON_BACK) != 0) { + mask |= com.codename1.ui.events.PointerEvent.MASK_BACK; + } + if ((buttonState & MotionEvent.BUTTON_FORWARD) != 0) { + mask |= com.codename1.ui.events.PointerEvent.MASK_FORWARD; + } + int button = com.codename1.ui.events.PointerEvent.BUTTON_PRIMARY; + if ((mask & com.codename1.ui.events.PointerEvent.MASK_SECONDARY) != 0) { + button = com.codename1.ui.events.PointerEvent.BUTTON_SECONDARY; + } else if ((mask & com.codename1.ui.events.PointerEvent.MASK_MIDDLE) != 0) { + button = com.codename1.ui.events.PointerEvent.BUTTON_MIDDLE; + } else if ((mask & com.codename1.ui.events.PointerEvent.MASK_BACK) != 0) { + button = com.codename1.ui.events.PointerEvent.BUTTON_BACK; + } else if ((mask & com.codename1.ui.events.PointerEvent.MASK_FORWARD) != 0) { + button = com.codename1.ui.events.PointerEvent.BUTTON_FORWARD; + } else if (mask == 0) { + mask = com.codename1.ui.events.PointerEvent.MASK_PRIMARY; + } + this.implementation.setPointerEventMetadata(button, mask, type, pressure, tiltX, 0, contactSize, + motionModifierMask(event), hovering); + } + + /** + * Builds the cross-platform keyboard modifier mask from an Android MotionEvent meta state. + */ + private int motionModifierMask(MotionEvent event) { + int meta = event.getMetaState(); + int modifiers = 0; + if ((meta & android.view.KeyEvent.META_SHIFT_ON) != 0) { + modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_SHIFT; + } + if ((meta & android.view.KeyEvent.META_CTRL_ON) != 0) { + modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_CONTROL; + } + if ((meta & android.view.KeyEvent.META_ALT_ON) != 0) { + modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_ALT; + } + if ((meta & android.view.KeyEvent.META_META_ON) != 0) { + modifiers |= com.codename1.ui.events.PointerEvent.MODIFIER_META; + } + return modifiers; + } + + public AndroidGraphics getGraphics() { + return buffy; + } + + public int getViewHeight() { + return height; + } + + public int getViewWidth() { + return width; + } + + public Rect getSafeArea() { + return safeArea; + } + + public void setInputType(EditorInfo editorInfo) { + + /** + * do not use the enter key to fire some kind of action! + */ +// editorInfo.imeOptions |= EditorInfo.IME_ACTION_NONE; + Component txtCmp = Display.getInstance().getCurrent().getFocused(); + if (txtCmp != null && txtCmp instanceof TextArea) { + TextArea txt = (TextArea) txtCmp; + if (txt.isSingleLineTextArea()) { + editorInfo.imeOptions |= EditorInfo.IME_ACTION_DONE; + + } else { + editorInfo.imeOptions |= EditorInfo.IME_ACTION_NONE; + } + int inputType = 0; + int constraint = txt.getConstraint(); + if ((constraint & TextArea.PASSWORD) == TextArea.PASSWORD) { + constraint = constraint ^ TextArea.PASSWORD; + } + switch (constraint) { + case TextArea.NUMERIC: + inputType = EditorInfo.TYPE_CLASS_NUMBER | EditorInfo.TYPE_NUMBER_FLAG_SIGNED; + break; + case TextArea.DECIMAL: + inputType = EditorInfo.TYPE_CLASS_NUMBER | EditorInfo.TYPE_NUMBER_FLAG_DECIMAL; + break; + case TextArea.PHONENUMBER: + inputType = EditorInfo.TYPE_CLASS_PHONE; + break; + case TextArea.EMAILADDR: + inputType = EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; + break; + case TextArea.URL: + inputType = EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_VARIATION_URI; + break; + default: + inputType = EditorInfo.TYPE_CLASS_TEXT; + break; + + } + + editorInfo.inputType = inputType; + } + } + + +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 893f0fd99e3..fa2b2d7f1d7 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -405,7 +405,14 @@ public com.codename1.wearable.spi.WearableBridge getWearableBridge() { /// from the same `codename1.watchMain` setting the device builds use, which the simulator /// launcher exposes as a system property. static String getWatchMainClass() { + // The system property is how the companion process is told what to run. A normal `mvn + // cn1:run` sets no such property, so fall back to the project settings on disk -- otherwise + // the whole watch feature would be invisible under the standard simulator launch. String s = System.getProperty("codename1.watchMain"); + if (s == null || s.trim().length() == 0) { + Properties cnop = loadCodenameOneSettings(); + s = cnop == null ? null : cnop.getProperty("codename1.watchMain"); + } if (s == null || s.trim().length() == 0) { return null; } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java index 4daa65af6d2..e99443b0e33 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java @@ -161,12 +161,22 @@ public void putData(String path, byte[] payload) { File f = dataFile(path); try { f.getParentFile().mkdirs(); - FileOutputStream out = new FileOutputStream(f); + // Write-then-rename: the peer polls this directory every 500ms, and writing in place + // would let it read a truncated payload mid-write and report a malformed value. + File tmp = new File(f.getParentFile(), f.getName() + ".tmp"); + FileOutputStream out = new FileOutputStream(tmp); try { - out.write(payload); + out.write(payload == null ? new byte[0] : payload); + out.flush(); } finally { out.close(); } + if (!tmp.renameTo(f)) { + f.delete(); + if (!tmp.renameTo(f)) { + throw new IOException("could not replace " + f); + } + } // Our own write must not come back to us as a peer change. synchronized (seenData) { seenData.put(f.getName(), Long.valueOf(f.lastModified())); @@ -204,7 +214,7 @@ public String[] getDataPaths() { } List out = new ArrayList(); for (File f : files) { - if (f.isFile()) { + if (f.isFile() && !f.getName().endsWith(".tmp")) { out.add(decodePath(f.getName())); } } @@ -365,20 +375,15 @@ public void run() { t.start(); } - /// Records what is already on disk without reporting it, so a restart does not replay every - /// value the app itself published last run. + /// Leaves what is already on disk unrecorded, so the first watcher pass replays it. + /// + /// A value the peer published while this side was stopped is exactly what a starting app needs + /// to see -- that is the guarantee replicated data makes, and recording the files as already + /// seen would silently break it. The cost is that a value this app published itself last run is + /// replayed to it too, which listeners handle the same way they handle any republish. private void primeSeenData() { - File[] files = dataDir.listFiles(); - if (files == null) { - return; - } - synchronized (seenData) { - for (File f : files) { - if (f.isFile()) { - seenData.put(f.getName(), Long.valueOf(f.lastModified())); - } - } - } + // Deliberately empty: see above. Kept as a named step so the reasoning has somewhere to + // live rather than being an absence. } private void scanData() { @@ -389,7 +394,7 @@ private void scanData() { } if (files != null) { for (File f : files) { - if (!f.isFile()) { + if (!f.isFile() || f.getName().endsWith(".tmp")) { continue; } gone.remove(f.getName()); diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.h b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.h index 78ccb6625b9..c7dcffe293a 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.h +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.h @@ -36,6 +36,11 @@ #define CN1WatchConnectivity_h #include "TargetConditionals.h" +// CN1_USE_WATCHCONNECTIVITY lives in the central header the builder edits. Every translation unit +// that tests it has to see that definition, so import it here rather than in the .m: without this +// the guard below is always false, the implementation compiles away, and the app fails to link +// against a class the natives call. +#import "CodenameOne_GLViewController.h" #if defined(CN1_USE_WATCHCONNECTIVITY) && !TARGET_OS_TV && !TARGET_OS_MACCATALYST diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m index 0b4689837af..415e24932e1 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -38,6 +38,8 @@ @implementation CN1WatchConnectivity { // asynchronously on the EDT, so the block has to outlive the delegate callback. NSMutableDictionary *)> *_pendingReplies; int _nextInboundToken; + /// Keys the peer's last context carried, so a key that vanishes is reported as a removal. + NSSet *_lastReceivedKeys; } + (CN1WatchConnectivity *)shared { @@ -55,6 +57,7 @@ - (instancetype)init { if (self != nil) { _pendingReplies = [[NSMutableDictionary alloc] init]; _nextInboundToken = 1; + _lastReceivedKeys = [[NSSet alloc] init]; } return self; } @@ -136,11 +139,14 @@ - (void)sendReply:(int)replyToken payload:(NSData *)payload { void (^handler)(NSDictionary *); @synchronized (_pendingReplies) { NSNumber *key = @(replyToken); - handler = _pendingReplies[key]; + // ARC is off in this port, so the dictionary's reference is the only one keeping the block + // alive: retain before removing, or the block is deallocated before it is called. + handler = [_pendingReplies[key] retain]; [_pendingReplies removeObjectForKey:key]; } if (handler != nil) { handler(@{kReplyKey: (payload == nil ? [NSData data] : payload)}); + [handler release]; } } @@ -157,7 +163,7 @@ - (void)putData:(NSString *)path payload:(NSData *)payload { } NSMutableDictionary *ctx = [[s applicationContext] mutableCopy]; if (ctx == nil) { - ctx = [NSMutableDictionary dictionary]; + ctx = [[NSMutableDictionary alloc] init]; } ctx[path] = (payload == nil ? [NSData data] : payload); NSError *err = nil; @@ -165,6 +171,7 @@ - (void)putData:(NSString *)path payload:(NSData *)payload { if (err != nil) { NSLog(@"[cn1.wearable] failed to publish %@: %@", path, err.localizedDescription); } + [ctx release]; } - (NSData *)getData:(NSString *)path { @@ -184,12 +191,17 @@ - (void)removeData:(NSString *)path { return; } NSMutableDictionary *ctx = [[s applicationContext] mutableCopy]; - if (ctx == nil || ctx[path] == nil) { + if (ctx == nil) { + return; + } + if (ctx[path] == nil) { + [ctx release]; return; } [ctx removeObjectForKey:path]; NSError *err = nil; [s updateApplicationContext:ctx error:&err]; + [ctx release]; } - (NSArray *)dataPaths { @@ -269,7 +281,11 @@ - (void)dispatchInbound:(NSDictionary *)message // Park the block so the Java side can answer after it has hopped to the EDT. @synchronized (_pendingReplies) { token = _nextInboundToken++; - _pendingReplies[@(token)] = [replyHandler copy]; + // -copy returns +1 under manual reference counting and the dictionary retains it too, + // so hand off the copy's ownership rather than leaking it. + void (^stored)(NSDictionary *) = [replyHandler copy]; + _pendingReplies[@(token)] = stored; + [stored release]; } } cn1_wearable_deliverMessage(path.UTF8String, body.bytes, (int) body.length, token); @@ -277,14 +293,22 @@ - (void)dispatchInbound:(NSDictionary *)message - (void)session:(WCSession *)session didReceiveApplicationContext:(NSDictionary *)applicationContext { - // The peer replaced its whole context; report every entry and let the Java listeners decide - // what changed. Contexts are small by design, so this is cheaper than diffing. + // The peer replaces its whole context on every publish, so a removal shows up as a key that has + // simply stopped being there. Report what is present, then whatever disappeared since last + // time -- otherwise removeData on one side is invisible on the other. for (NSString *path in applicationContext) { NSData *body = applicationContext[path]; if ([body isKindOfClass:[NSData class]]) { cn1_wearable_deliverDataChanged(path.UTF8String, body.bytes, (int) body.length); } } + for (NSString *gone in _lastReceivedKeys) { + if (applicationContext[gone] == nil) { + cn1_wearable_deliverDataRemoved(gone.UTF8String); + } + } + [_lastReceivedKeys release]; + _lastReceivedKeys = [[NSSet setWithArray:applicationContext.allKeys] retain]; } - (void)session:(WCSession *)session didReceiveFile:(WCSessionFile *)file { 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 99b0e6902fb..7edf6e7b41f 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 @@ -3241,6 +3241,10 @@ public void usesClassMethod(String cls, String method) { String wearableListenerService = ""; if (usesWearable) { wearableListenerService = + // Exported because Play services binds it -- that is not optional for a + // WearableListenerService. There is no binding permission Play services holds + // that would narrow it, so the service validates the source node of every event + // instead (see CN1WearableListenerService). " \n" + " \n" + " \n" 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 4b5db11c9a3..d6725330da2 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 @@ -26,6 +26,7 @@ import android.net.Uri; import com.codename1.wearable.WearableConnection; +import com.codename1.wearable.WearableMessage; import com.codename1.wearable.spi.WearableBridge; import com.google.android.gms.tasks.Tasks; @@ -41,7 +42,9 @@ import com.google.android.gms.wearable.Wearable; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; /** @@ -70,6 +73,15 @@ public class CN1WearableBridge implements WearableBridge { private static final String PAYLOAD_KEY = "cn1.payload"; /** How long a blocking Data Layer call may take before we give up and answer "not available". */ private static final long TIMEOUT_SECONDS = 5; + /** + * The Codename One EDT must never wait five seconds on Play services -- isPaired/isReachable are + * exactly the sort of thing an app calls from init() or a button handler. The node list is + * therefore cached and refreshed off the EDT; callers get the last known answer immediately. + */ + private static final long NODE_CACHE_MILLIS = 3000; + private volatile List cachedNodes = new ArrayList(); + private volatile long cachedNodesStamp; + private volatile boolean refreshingNodes; private final Context context; private final MessageClient messageClient; @@ -101,9 +113,53 @@ public boolean isSupported() { } public boolean isPaired() { - return !connectedNodes().isEmpty(); + // Pairing, not reachability: a paired watch that is switched off or out of range reports no + // connected node, and the API promises these are different questions. + return !connectedNodes().isEmpty() || !bondedNodeIds().isEmpty(); + } + + /// Ids of the nodes the Data Layer currently reports, for the listener service's caller check. + /// + /// @param context any context; the Data Layer clients are cheap to obtain + /// @return the connected node ids, never null + static List connectedNodeIds(Context context) { + List out = new ArrayList(); + try { + List nodes = Tasks.await( + Wearable.getNodeClient(context.getApplicationContext()).getConnectedNodes(), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + for (Node n : nodes) { + out.add(n.getId()); + } + } catch (Throwable unavailable) { + // Nothing reachable: nothing is trusted. + } + return out; } + /// Nodes the Data Layer knows about whether or not they are currently connected. + private List bondedNodeIds() { + if (com.codename1.ui.CN.isEdt()) { + // Never block the EDT; the cached answer is refreshed off it. + return cachedBonded; + } + List out = new ArrayList(); + try { + CapabilityInfo info = Tasks.await( + capabilityClient.getCapability("cn1_wearable", CapabilityClient.FILTER_ALL), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + for (Node n : info.getNodes()) { + out.add(n.getId()); + } + } catch (Throwable unavailable) { + // No capability info: fall back to "nothing known". + } + cachedBonded = out; + return out; + } + + private volatile List cachedBonded = new ArrayList(); + public boolean isReachable() { for (Node n : connectedNodes()) { if (n.isNearby()) { @@ -130,12 +186,48 @@ public String[] getConnectedNodes() { return out; } + /** + * The nodes last seen, refreshed in the background. Blocking is only acceptable off the EDT -- + * on it, a stale answer now beats a correct answer after a five-second freeze. + */ private List connectedNodes() { + long age = System.currentTimeMillis() - cachedNodesStamp; + if (age > NODE_CACHE_MILLIS) { + if (com.codename1.ui.CN.isEdt()) { + refreshNodesAsync(); + } else { + refreshNodesNow(); + } + } + return cachedNodes; + } + + private void refreshNodesNow() { try { - return Tasks.await(nodeClient.getConnectedNodes(), TIMEOUT_SECONDS, TimeUnit.SECONDS); + cachedNodes = Tasks.await(nodeClient.getConnectedNodes(), + TIMEOUT_SECONDS, TimeUnit.SECONDS); } catch (Throwable unavailable) { - return new ArrayList(); + cachedNodes = new ArrayList(); } + cachedNodesStamp = System.currentTimeMillis(); + } + + private void refreshNodesAsync() { + if (refreshingNodes) { + return; + } + refreshingNodes = true; + nodeClient.getConnectedNodes().addOnCompleteListener( + new com.google.android.gms.tasks.OnCompleteListener>() { + public void onComplete(com.google.android.gms.tasks.Task> task) { + cachedNodes = task.isSuccessful() && task.getResult() != null + ? task.getResult() : new ArrayList(); + cachedNodesStamp = System.currentTimeMillis(); + refreshingNodes = false; + // Reachability may have changed; let listeners re-query. + WearableConnection.notifyStateChanged(); + } + }); } // --- messages ----------------------------------------------------------- @@ -149,8 +241,23 @@ public void sendMessage(String path, byte[] payload, int replyToken) { } // The peer needs both the CN1 path and, when an answer is wanted, the token to answer // with. Both ride in the Data Layer path so the payload stays exactly the app's bytes. - String wire = (replyToken == 0 ? MESSAGE_PATH : REQUEST_PATH + replyToken) + encode(path); - messageClient.sendMessage(n.getId(), wire, payload); + // The '/' after the token is the delimiter the listener splits on; a CN1 path is only + // conventionally slash-prefixed, so add one rather than assuming it. + String wire = (replyToken == 0 ? MESSAGE_PATH : REQUEST_PATH + replyToken) + + slashPrefixed(encode(path)); + com.google.android.gms.tasks.Task task = + messageClient.sendMessage(n.getId(), wire, payload); + if (replyToken != 0) { + // Discovery can hand back a node that disconnects before the send lands. Without + // this the caller's reply handler is never completed at all. + final int token = replyToken; + task.addOnFailureListener(new com.google.android.gms.tasks.OnFailureListener() { + public void onFailure(Exception e) { + WearableConnection.deliverReply(token, null, + "The message could not be delivered: " + e.getMessage()); + } + }); + } sentToAnyone = true; } if (!sentToAnyone && replyToken != 0) { @@ -159,11 +266,30 @@ public void sendMessage(String path, byte[] payload, int replyToken) { } public void sendReply(int replyToken, byte[] payload) { - for (Node n : connectedNodes()) { - if (n.isNearby()) { - messageClient.sendMessage(n.getId(), REPLY_PATH + replyToken, payload); - } + // Back to the node that asked, not to every watch on the wrist rack: tokens are allocated + // per node and routinely collide, so broadcasting would answer the wrong request. + String node; + synchronized (inboundNodes) { + node = inboundNodes.remove(Integer.valueOf(replyToken)); + } + if (node == null) { + return; } + messageClient.sendMessage(node, REPLY_PATH + replyToken, payload); + } + + /// Records which node sent a request, so its answer can be routed back to it. Called by + /// {@link CN1WearableListenerService} as the request arrives. + static void rememberRequestOrigin(int replyToken, String nodeId) { + synchronized (inboundNodes) { + inboundNodes.put(Integer.valueOf(replyToken), nodeId); + } + } + + private static final Map inboundNodes = new HashMap(); + + private static String slashPrefixed(String path) { + return path.startsWith("/") ? path : "/" + path; } // --- replicated data ---------------------------------------------------- @@ -178,7 +304,9 @@ public void putData(String path, byte[] payload) { public byte[] getData(String path) { try { - Uri uri = new Uri.Builder().scheme("wear").path(dataPath(path)).build(); + // The authority is required: a wear:// Uri without one matches nothing. "*" means + // "any node", which is what a reader wants -- the value may have come from either side. + Uri uri = new Uri.Builder().scheme("wear").authority("*").path(dataPath(path)).build(); DataItemBuffer items = Tasks.await(dataClient.getDataItems(uri), TIMEOUT_SECONDS, TimeUnit.SECONDS); try { @@ -195,7 +323,7 @@ public byte[] getData(String path) { } public void removeData(String path) { - Uri uri = new Uri.Builder().scheme("wear").path(dataPath(path)).build(); + Uri uri = new Uri.Builder().scheme("wear").authority("*").path(dataPath(path)).build(); dataClient.deleteDataItems(uri); } @@ -222,9 +350,13 @@ public String[] getDataPaths() { public void transferFile(String path, String name, byte[] contents) { // A DataItem already syncs in the background and survives both apps being killed, which is - // the guarantee a file transfer makes. Naming it under the path keeps several files from - // overwriting each other. - putData(path + "/" + (name == null ? "file" : name), contents); + // the guarantee a file transfer makes. The bytes are the file's own, not a WearableMessage, + // so wrap them in one -- the receiver decodes every DataItem as a payload and would + // otherwise read a PNG as a malformed message. + WearableMessage wrapper = new WearableMessage(path) + .put("name", name == null ? "file" : name) + .put("contents", contents == null ? new byte[0] : contents); + putData(path + "/" + (name == null ? "file" : name), wrapper.toByteArray()); } // --- paths -------------------------------------------------------------- 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 3043affb051..d2901e4054e 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 @@ -41,10 +41,28 @@ */ public class CN1WearableListenerService extends WearableListenerService { + /** + * The service has to be exported for Play services to bind it, and there is no binding + * permission that would narrow that to Play services alone. So rather than trust the caller, + * every event is checked against the nodes the Data Layer actually reports: a crafted intent + * from another app on the device carries a source node that is not one of them and is dropped. + */ + private boolean isFromAKnownNode(String sourceNodeId) { + if (sourceNodeId == null || sourceNodeId.length() == 0) { + return false; + } + for (String id : CN1WearableBridge.connectedNodeIds(this)) { + if (sourceNodeId.equals(id)) { + return true; + } + } + return false; + } + @Override public void onMessageReceived(MessageEvent event) { String path = event.getPath(); - if (path == null) { + if (path == null || !isFromAKnownNode(event.getSourceNodeId())) { return; } if (path.startsWith(CN1WearableBridge.replyPath())) { @@ -67,6 +85,9 @@ public void onMessageReceived(MessageEvent event) { } try { int token = Integer.parseInt(rest.substring(0, slash)); + // Remember who asked so the answer goes back to that watch: tokens are allocated per + // node and collide across them, so a broadcast reply would answer the wrong request. + CN1WearableBridge.rememberRequestOrigin(token, event.getSourceNodeId()); WearableConnection.deliverMessage( CN1WearableBridge.decode(rest.substring(slash)), event.getData(), token); } catch (NumberFormatException malformed) { From 8d1574af4d8ee0a590fd5b4fa8cd21715a04190b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:56:33 +0300 Subject: [PATCH 012/250] Address the remaining review findings - A standalone Wear OS build now roots the generated stub at codename1.watchMain. It reached the manifest but nothing else, so the single APK still started the phone UI -- which is the opposite of what "the watch app is the product" means. - The Wear listener service brings the app up when the Data Layer starts it in a dead process. Queueing the delivery was only half the answer: with nothing starting the app, no listener ever registered and the queue was never drained. - The simulator checks the watch skin is on the classpath before launching the companion. Without it the second window came up on a phone skin with CN.isWatch() false, which looks like the feature silently not working. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/impl/javase/JavaSEPort.java | 11 ++++++++ .../builders/AndroidGradleBuilder.java | 27 ++++++++++++++++--- .../wearable/CN1WearableListenerService.java | 26 ++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index fa2b2d7f1d7..1978bda884c 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -5667,6 +5667,17 @@ void launchWatchCompanion() { "Watch App", javax.swing.JOptionPane.INFORMATION_MESSAGE); return; } + if (JavaSEPort.class.getResource(WATCH_COMPANION_SKIN) == null) { + // Without the skin the companion comes up on a phone skin, CN.isWatch() stays false and + // the whole point of the window is lost -- say so rather than launching something + // misleading. + javax.swing.JOptionPane.showMessageDialog(window, + "The watch skin " + WATCH_COMPANION_SKIN + " is not on the classpath.\n\n" + + "It ships with the Codename One JavaSE port; a stale or partial build of that\n" + + "port is the usual cause. Rebuild it and try again.", + "Watch App", javax.swing.JOptionPane.ERROR_MESSAGE); + return; + } try { List cmd = new ArrayList(); cmd.add(new File(new File(System.getProperty("java.home"), "bin"), "java").getAbsolutePath()); 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 7edf6e7b41f..b8c9a046fe6 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 @@ -314,6 +314,27 @@ public File getGradleProjectDirectory() { // play-services-wearable dependency, the WearableListenerService manifest entry and the // injected Data Layer glue. private boolean usesWearable; + + /** + * The lifecycle class the generated stub instantiates. + * + *

Normally the phone main class. In a standalone Wear OS build the watch app is the product + * -- there is no phone app beside it -- so the single APK is rooted at {@code + * codename1.watchMain} instead; without this the watch declaration only reached the manifest + * and the app still started the phone UI. + * + * @param request the build being generated + * @return the class name the stub should instantiate + */ + private static String appLifecycleClass(BuildRequest request) { + String watchMain = request.getArg("watchMain", "").trim(); + boolean standalone = "true".equals(request.getArg("watchStandalone", "false")); + if (watchMain.length() > 0 && standalone) { + return watchMain; + } + return request.getMainClass(); + } + private boolean usesOidc; private boolean usesAppleSignIn; private boolean usesWebauthn; @@ -4124,14 +4145,14 @@ public void usesClassMethod(String cls, String method) { + " public static final String LICENSE_KEY = \"" + xorEncode(licenseKey) + "\";\n" + " String [] consumable = new String[]{" + consumable + "};\n" + " private static " + request.getMainClass() + "Stub stubInstance;\n" - + " private static " + request.getMainClass() + " i;\n" + + " private static " + appLifecycleClass(request) + " i;\n" + " private boolean running;\n" + " private" + firstTimeStatic + " boolean firstTime = true;\n" + " private Form currentForm;\n" + " private static final Object LOCK = new Object();\n" + additionalMembers + headphonesVars - + " public static " + request.getMainClass() + " getAppInstance() {\n" + + " public static " + appLifecycleClass(request) + " getAppInstance() {\n" + " return i;\n" + " }\n\n" + activityBillingSource @@ -4191,7 +4212,7 @@ public void usesClassMethod(String cls, String method) { + reinitCode + " }\n" + " if (i == null) {\n" - + " i = new " + request.getMainClass() + "();\n" + + " i = new " + appLifecycleClass(request) + "();\n" + " if(i instanceof PushCallback) {\n" + " com.codename1.impl.CodenameOneImplementation.setPushCallback((PushCallback)i);\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 d2901e4054e..37720409770 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 @@ -59,12 +59,37 @@ private boolean isFromAKnownNode(String sourceNodeId) { return false; } + /** + * Brings the app process up so its {@code init()} runs and its listeners exist. + * + *

Android starts this service in a dead process to deliver traffic. Queueing the delivery is + * only half the answer: without the app itself starting, nothing ever registers a listener and + * the queue is never drained. Launching is a no-op when the app is already running. + */ + private void ensureAppRunning() { + try { + if (com.codename1.ui.Display.isInitialized()) { + return; + } + android.content.Intent launch = getPackageManager() + .getLaunchIntentForPackage(getApplicationInfo().packageName); + if (launch != null) { + launch.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(launch); + } + } catch (Throwable notPermitted) { + // Background activity starts are restricted on newer Android; the delivery stays queued + // and is replayed the next time the user opens the app. + } + } + @Override public void onMessageReceived(MessageEvent event) { String path = event.getPath(); if (path == null || !isFromAKnownNode(event.getSourceNodeId())) { return; } + ensureAppRunning(); if (path.startsWith(CN1WearableBridge.replyPath())) { // An answer to a request we sent. The token rides in the path. String token = path.substring(CN1WearableBridge.replyPath().length()); @@ -104,6 +129,7 @@ public void onMessageReceived(MessageEvent event) { @Override public void onDataChanged(DataEventBuffer events) { + ensureAppRunning(); for (DataEvent event : events) { String path = event.getDataItem().getUri().getPath(); if (path == null || !path.startsWith(CN1WearableBridge.pathPrefix())) { From d4448c6c8086a2cee9bd25654af4351f8d1f1779 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:00:29 +0300 Subject: [PATCH 013/250] Advertise a wearable capability, and say when no Wear APK is produced isCompanionAppInstalled asked the node list, which answers "is a device connected" rather than "is that device running this app" -- a bare watch reported the companion as installed. The build now declares a cn1_wearable capability that the peer half advertises, and the question is asked of that. A build with codename1.watchMain but no codename1.watchStandalone now logs that the companion Wear APK is not generated yet, so the gap is visible where a developer is looking rather than only in the guide. Mirrored to the BuildDaemon. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 29 +++++++++++++++++++ .../builders/wearable/CN1WearableBridge.java | 8 +++-- 2 files changed, 34 insertions(+), 3 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 b8c9a046fe6..db6a2d135aa 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,11 @@ public File getGradleProjectDirectory() { * @param request the build being generated * @return the class name the stub should instantiate */ + /** The declared watch lifecycle class, or an empty string when the project declares none. */ + private static String watchMainClass(BuildRequest request) { + return request.getArg("watchMain", "").trim(); + } + private static String appLifecycleClass(BuildRequest request) { String watchMain = request.getArg("watchMain", "").trim(); boolean standalone = "true".equals(request.getArg("watchStandalone", "false")); @@ -2349,6 +2354,30 @@ public void usesClassMethod(String cls, String method) { } } playServicesWear = true; + // The capability the peer half advertises, so isCompanionAppInstalled() can tell a + // watch running this app from a watch that merely exists. + File wearValues = new File(projectDir, "app/src/main/res/values"); + wearValues.mkdirs(); + try { + createFile(new File(wearValues, "cn1_wearable.xml"), + ("\n" + + "\n" + + " \n" + + " cn1_wearable\n" + + " \n" + + "\n").getBytes("UTF-8")); + } catch (IOException ex) { + throw new BuildException("Failed to write the wearable capability declaration", ex); + } + } + if (watchMainClass(request).length() > 0 + && !"true".equals(request.getArg("watchStandalone", "false"))) { + // Say so rather than quietly producing one artifact: a companion Wear APK is not + // generated yet (see the wearables chapter of the developer guide). + log("[wearable] codename1.watchMain is set without codename1.watchStandalone. The " + + "Apple Watch companion is built, but a companion Wear OS APK is not produced " + + "yet -- set codename1.watchStandalone=true to build the watch app as the " + + "Android product."); } // External surfaces (com.codename1.surfaces): parse the build-time kinds manifest, 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 d6725330da2..90d2af97c42 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 @@ -170,9 +170,11 @@ public boolean isReachable() { } public boolean isCompanionAppInstalled() { - // A node only appears in the Data Layer's node list when it is running a build of this same - // app, so a connected node is the same answer. - return !connectedNodes().isEmpty(); + // A connected node is a connected *device*, not a device running this app -- so the node + // list alone would report a bare watch as having the companion installed. The peer half + // advertises the "cn1_wearable" capability (declared in res/values/cn1_wearable.xml by the + // build), so asking who advertises it is the actual question. + return !bondedNodeIds().isEmpty(); } public String[] getConnectedNodes() { From b8a27f2bbf304fdc19e40f6a79384aba0849908a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:11:24 +0300 Subject: [PATCH 014/250] Fix the second round of review findings - A send on a cold cache fanned out to nobody and reported "no nearby device" with a watch sitting right there. The first send now waits for the initial discovery instead of trusting a cache that has never been filled. - Listener registration raced the cold-start queue: a delivery could be parked after the drain meant to replay it. The check, the enqueue and the drain now share one monitor. - A reply-bearing fan-out failed the handler as soon as any single node's send failed, cancelling a reply another node was about to give. It now fails only when no node accepted. - Inbound reply origins were keyed by the peer's token, which is unique only on the peer -- two watches picking the same number overwrote each other. The peer token is traded for a locally unique one keyed to the node. - The listener service declared only the specific Data Layer actions; without BIND_LISTENER Play services never binds it and no callback arrives at all. - The iPhone lock screen and the watch face share accessoryRectangular, so the key order is now platform-conditional and each surface gets its own layout. - A received file on iOS was handed over as raw bytes where the receiver decodes a payload, losing both contents and name. It is wrapped as the Android side already does. - A malformed payload with a negative length threw NegativeArraySizeException past the decoder's handler and onto the EDT, breaking fromByteArray's promise to answer with an empty message. Lengths are validated before allocating. Mirrored to the BuildDaemon. Co-Authored-By: Claude Opus 5 (1M context) --- .../wearable/WearableConnection.java | 24 +++-- .../codename1/wearable/WearableMessage.java | 20 +++- .../nativeSources/CN1WatchConnectivity.m | 44 ++++++++- .../builders/AndroidGradleBuilder.java | 4 + .../surfaces/ios/CN1DescriptorWidget.swift | 6 ++ .../builders/wearable/CN1WearableBridge.java | 95 ++++++++++++++----- .../wearable/CN1WearableListenerService.java | 11 ++- 7 files changed, 163 insertions(+), 41 deletions(-) diff --git a/CodenameOne/src/com/codename1/wearable/WearableConnection.java b/CodenameOne/src/com/codename1/wearable/WearableConnection.java index 860894fd7fc..93fc4a4adfd 100644 --- a/CodenameOne/src/com/codename1/wearable/WearableConnection.java +++ b/CodenameOne/src/com/codename1/wearable/WearableConnection.java @@ -332,7 +332,9 @@ public static void transferFile(String path, String name, byte[] contents) { /// - `l`: the listener to add public static void addMessageListener(WearableMessageListener l) { if (l != null && !messageListeners.contains(l)) { - messageListeners.add(l); + synchronized (pendingMessages) { + messageListeners.add(l); + } activate(); drainPending(pendingMessages); } @@ -355,7 +357,9 @@ public static void removeMessageListener(WearableMessageListener l) { /// - `l`: the listener to add public static void addDataListener(WearableDataListener l) { if (l != null && !dataListeners.contains(l)) { - dataListeners.add(l); + synchronized (pendingData) { + dataListeners.add(l); + } activate(); drainPending(pendingData); } @@ -425,7 +429,7 @@ public void run() { } } } - }, !messageListeners.isEmpty(), pendingMessages); + }, messageListeners, pendingMessages); } /// Framework/port entry point: hands the peer's answer to the waiting reply handler. Called by @@ -477,7 +481,7 @@ public void run() { l.dataChanged(m); } } - }, !dataListeners.isEmpty(), pendingData); + }, dataListeners, pendingData); } /// Framework/port entry point: reports that the peer removed a replicated value. Called by the @@ -496,7 +500,7 @@ public void run() { l.dataRemoved(path); } } - }, !dataListeners.isEmpty(), pendingData); + }, dataListeners, pendingData); } /// Framework/port entry point: reports that reachability, pairing or peer-app installation @@ -520,12 +524,14 @@ public void run() { /// The platform starts an app to hand it a payload, so the payload routinely arrives before the /// app has finished wiring itself up. Parking rather than dropping is what makes it safe to /// register listeners in `init()`. - private static void deliver(Runnable delivery, boolean hasListener, List queue) { - if (!hasListener) { - synchronized (queue) { + private static void deliver(Runnable delivery, List listeners, List queue) { + // The listener check and the enqueue share the queue's monitor with drainPending, so a + // delivery can never be parked after the drain that would have replayed it. + synchronized (queue) { + if (listeners.isEmpty()) { queue.add(delivery); + return; } - return; } Display.getInstance().callSerially(delivery); } diff --git a/CodenameOne/src/com/codename1/wearable/WearableMessage.java b/CodenameOne/src/com/codename1/wearable/WearableMessage.java index 0c3d75ce859..c61469f7406 100644 --- a/CodenameOne/src/com/codename1/wearable/WearableMessage.java +++ b/CodenameOne/src/com/codename1/wearable/WearableMessage.java @@ -314,11 +314,25 @@ private static void writeLongUTF(DataOutputStream out, String value) throws IOEx /// Reads a string written by [#writeLongUTF(DataOutputStream,String)]. private static String readLongUTF(DataInputStream in) throws IOException { - byte[] utf8 = new byte[in.readInt()]; + byte[] utf8 = new byte[readLength(in)]; in.readFully(utf8); return new String(utf8, "UTF-8"); } + /// Reads a length that is about to size an allocation. + /// + /// A negative or absurd value means the payload is malformed or came from a peer this build + /// does not understand. Throwing IOException keeps that inside the decoder's own handler, which + /// answers with an empty message -- an unchecked NegativeArraySizeException would escape onto + /// the EDT instead. + private static int readLength(DataInputStream in) throws IOException { + int n = in.readInt(); + if (n < 0 || n > in.available() + 1) { + throw new IOException("Implausible length " + n + " in a wearable payload"); + } + return n; + } + // --- wire format -------------------------------------------------------- /// Serializes the payload to the compact form the platform bridges carry. Application code does @@ -419,7 +433,7 @@ public static WearableMessage fromByteArray(String path, byte[] data) { m.put(key, in.readBoolean()); break; case TYPE_BYTES: - byte[] b = new byte[in.readInt()]; + byte[] b = new byte[readLength(in)]; in.readFully(b); m.put(key, b); break; @@ -430,7 +444,7 @@ public static WearableMessage fromByteArray(String path, byte[] data) { } } } catch (IOException err) { - com.codename1.io.Log.p("Wearable: truncated payload on " + path + ": " + err); + com.codename1.io.Log.p("Wearable: unreadable payload on " + path + ": " + err); } return m; } diff --git a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m index 415e24932e1..9720933fc7b 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m +++ b/Ports/iOSPort/nativeSources/CN1WatchConnectivity.m @@ -33,6 +33,41 @@ static NSString *const kTokenKey = @"cn1.token"; static NSString *const kReplyKey = @"cn1.reply"; + +/// Builds the WearableMessage wire form for a received file: a two-entry payload carrying "name" +/// (string) and "contents" (bytes). Mirrors com.codename1.wearable.WearableMessage#toByteArray, so +/// the shapes have to stay in step -- see FORMAT_VERSION there. +static NSData *cn1WearableWrapFile(NSString *name, NSData *contents) { + const uint8_t kFormatVersion = 1; + const uint8_t kTypeString = 1; + const uint8_t kTypeBytes = 6; + NSMutableData *out = [NSMutableData data]; + [out appendBytes:&kFormatVersion length:1]; + uint16_t count = CFSwapInt16HostToBig(2); + [out appendBytes:&count length:2]; + + NSData *nameKey = [@"name" dataUsingEncoding:NSUTF8StringEncoding]; + NSData *nameVal = [(name == nil ? @"file" : name) dataUsingEncoding:NSUTF8StringEncoding]; + NSData *bodyKey = [@"contents" dataUsingEncoding:NSUTF8StringEncoding]; + + uint32_t len = CFSwapInt32HostToBig((uint32_t) nameKey.length); + [out appendBytes:&len length:4]; + [out appendData:nameKey]; + [out appendBytes:&kTypeString length:1]; + len = CFSwapInt32HostToBig((uint32_t) nameVal.length); + [out appendBytes:&len length:4]; + [out appendData:nameVal]; + + len = CFSwapInt32HostToBig((uint32_t) bodyKey.length); + [out appendBytes:&len length:4]; + [out appendData:bodyKey]; + [out appendBytes:&kTypeBytes length:1]; + len = CFSwapInt32HostToBig((uint32_t) contents.length); + [out appendBytes:&len length:4]; + [out appendData:contents]; + return out; +} + @implementation CN1WatchConnectivity { // Reply blocks for messages the peer sent us that expect an answer. The Java side answers // asynchronously on the EDT, so the block has to outlive the delegate callback. @@ -312,11 +347,16 @@ - (void)session:(WCSession *)session } - (void)session:(WCSession *)session didReceiveFile:(WCSessionFile *)file { + // The only delivery path decodes bytes as a WearableMessage, so raw file contents would arrive + // as a malformed payload with the name lost. Encode name+contents into one, matching what the + // Android bridge publishes for a transfer. NSString *path = file.metadata[kPathKey]; NSData *body = [NSData dataWithContentsOfURL:file.fileURL]; - if (body != nil) { - cn1_wearable_deliverDataChanged(path.UTF8String, body.bytes, (int) body.length); + if (body == nil) { + return; } + NSData *wrapped = cn1WearableWrapFile(file.fileURL.lastPathComponent, body); + cn1_wearable_deliverDataChanged(path.UTF8String, wrapped.bytes, (int) wrapped.length); } @end 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 db6a2d135aa..f25cfeca301 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 @@ -3297,6 +3297,10 @@ public void usesClassMethod(String cls, String method) { // instead (see CN1WearableListenerService). " \n" + " \n" + // BIND_LISTENER is how Play services binds the service at all; the specific + // actions below narrow what it delivers. Without it nothing binds and no + // callback ever arrives. + + " \n" + " \n" + " \n" + " \n" 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 db41f145e7d..08b46f96bf0 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 @@ -87,7 +87,13 @@ func cn1LayoutForFamily(_ layouts: [String: Any], family: WidgetFamily) -> [Stri case .accessoryCircular: keys = ["watchCircular"] case .accessoryRectangular: + // The same family serves the iPhone lock screen and the watch face, so each surface + // has to prefer the layout that was designed for it. +#if os(watchOS) keys = ["watchRectangular", "lockscreen"] +#else + keys = ["lockscreen", "watchRectangular"] +#endif case .accessoryInline: keys = ["watchInline"] default: 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 90d2af97c42..09475b9467d 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 @@ -234,9 +234,30 @@ public void onComplete(com.google.android.gms.tasks.Task> task) { // --- messages ----------------------------------------------------------- - public void sendMessage(String path, byte[] payload, int replyToken) { + public void sendMessage(final String path, final byte[] payload, final int replyToken) { + if (cachedNodesStamp == 0) { + // Nothing has been discovered yet. Sending now would fan out to an empty list and + // report "no nearby device" while a watch is sitting right there, so wait for the + // first refresh instead of trusting a cache that has never been filled. + nodeClient.getConnectedNodes().addOnCompleteListener( + new com.google.android.gms.tasks.OnCompleteListener>() { + public void onComplete(com.google.android.gms.tasks.Task> task) { + cachedNodes = task.isSuccessful() && task.getResult() != null + ? task.getResult() : new ArrayList(); + cachedNodesStamp = System.currentTimeMillis(); + fanOut(path, payload, replyToken); + } + }); + return; + } + fanOut(path, payload, replyToken); + } + + private void fanOut(String path, byte[] payload, final int replyToken) { List nodes = connectedNodes(); boolean sentToAnyone = false; + List> tasks = + new ArrayList>(); for (Node n : nodes) { if (!n.isNearby()) { continue; @@ -247,48 +268,78 @@ public void sendMessage(String path, byte[] payload, int replyToken) { // conventionally slash-prefixed, so add one rather than assuming it. String wire = (replyToken == 0 ? MESSAGE_PATH : REQUEST_PATH + replyToken) + slashPrefixed(encode(path)); - com.google.android.gms.tasks.Task task = - messageClient.sendMessage(n.getId(), wire, payload); + tasks.add(messageClient.sendMessage(n.getId(), wire, payload)); + sentToAnyone = true; + } + if (!sentToAnyone) { if (replyToken != 0) { - // Discovery can hand back a node that disconnects before the send lands. Without - // this the caller's reply handler is never completed at all. - final int token = replyToken; - task.addOnFailureListener(new com.google.android.gms.tasks.OnFailureListener() { - public void onFailure(Exception e) { - WearableConnection.deliverReply(token, null, - "The message could not be delivered: " + e.getMessage()); - } - }); + WearableConnection.deliverReply(replyToken, null, + "No nearby device is running the app"); } - sentToAnyone = true; + return; } - if (!sentToAnyone && replyToken != 0) { - WearableConnection.deliverReply(replyToken, null, "No nearby device is running the app"); + if (replyToken != 0) { + // Fail only when NO node accepted the request: one watch failing while another + // succeeds must not cancel the handler that the successful one is about to answer. + com.google.android.gms.tasks.Tasks.whenAllComplete(tasks).addOnCompleteListener( + new com.google.android.gms.tasks.OnCompleteListener>>() { + public void onComplete( + com.google.android.gms.tasks.Task>> all) { + if (all.getResult() == null) { + return; + } + for (com.google.android.gms.tasks.Task t : all.getResult()) { + if (t.isSuccessful()) { + return; + } + } + WearableConnection.deliverReply(replyToken, null, + "The message could not be delivered to any paired device"); + } + }); } } public void sendReply(int replyToken, byte[] payload) { // Back to the node that asked, not to every watch on the wrist rack: tokens are allocated // per node and routinely collide, so broadcasting would answer the wrong request. - String node; + // Two watches can allocate the same token before either is answered, so the origin is + // keyed by node AND token; the local token handed to Java is unique on its own. + InboundRequest req; synchronized (inboundNodes) { - node = inboundNodes.remove(Integer.valueOf(replyToken)); + req = inboundNodes.remove(Integer.valueOf(replyToken)); } - if (node == null) { + if (req == null) { return; } - messageClient.sendMessage(node, REPLY_PATH + replyToken, payload); + messageClient.sendMessage(req.nodeId, REPLY_PATH + req.peerToken, payload); } /// Records which node sent a request, so its answer can be routed back to it. Called by /// {@link CN1WearableListenerService} as the request arrives. - static void rememberRequestOrigin(int replyToken, String nodeId) { + static int rememberRequestOrigin(int peerToken, String nodeId) { synchronized (inboundNodes) { - inboundNodes.put(Integer.valueOf(replyToken), nodeId); + int local = nextLocalToken++; + inboundNodes.put(Integer.valueOf(local), new InboundRequest(nodeId, peerToken)); + return local; + } + } + + /// Who asked, and what token they used. Their token is theirs alone; ours identifies the + /// request locally so two nodes cannot collide. + private static final class InboundRequest { + final String nodeId; + final int peerToken; + + InboundRequest(String nodeId, int peerToken) { + this.nodeId = nodeId; + this.peerToken = peerToken; } } - private static final Map inboundNodes = new HashMap(); + private static final Map inboundNodes = + new HashMap(); + private static int nextLocalToken = 1; private static String slashPrefixed(String path) { return path.startsWith("/") ? path : "/" + path; 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 37720409770..75117410e5d 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 @@ -109,12 +109,13 @@ public void onMessageReceived(MessageEvent event) { return; } try { - int token = Integer.parseInt(rest.substring(0, slash)); - // Remember who asked so the answer goes back to that watch: tokens are allocated per - // node and collide across them, so a broadcast reply would answer the wrong request. - CN1WearableBridge.rememberRequestOrigin(token, event.getSourceNodeId()); + int peerToken = Integer.parseInt(rest.substring(0, slash)); + // The peer's token is unique only on the peer, so trade it for a locally unique one + // keyed to the node that asked; two watches can otherwise pick the same number. + int localToken = CN1WearableBridge.rememberRequestOrigin( + peerToken, event.getSourceNodeId()); WearableConnection.deliverMessage( - CN1WearableBridge.decode(rest.substring(slash)), event.getData(), token); + CN1WearableBridge.decode(rest.substring(slash)), event.getData(), localToken); } catch (NumberFormatException malformed) { // Not ours. } From 14ac12c5ec255e2751ee0a452852c5246d52e07b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:14:51 +0300 Subject: [PATCH 015/250] Keep the push helpers and the capability cache honest The generated push helpers declared their local as the phone main class while getAppInstance() now returns the watch lifecycle type, so a standalone Wear build with a distinct watchMain and a non-FCM push service would not compile at all. All four sites use the same helper as the stub. bondedNodeIds returned the cache unconditionally on the EDT and nothing ever filled it, so an installed companion was reported absent indefinitely. It now kicks off an async refresh like the node list does and notifies listeners when the answer lands. Mirrored to the BuildDaemon. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 8 +++--- .../builders/wearable/CN1WearableBridge.java | 27 ++++++++++++++++++- 2 files changed, 30 insertions(+), 5 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 f25cfeca301..f8e437cb05b 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 @@ -4467,7 +4467,7 @@ public void usesClassMethod(String cls, String method) { + " public PushCallback getPushCallbackInstance() {\n" + " if(" + handlePushImmediatelyCheck + ") {\n" + " " + request.getMainClass() + "Stub stub = " + request.getMainClass() + "Stub.getInstance();\n" - + " final " + request.getMainClass() + " main = stub.getAppInstance();\n" + + " final " + appLifecycleClass(request) + " main = stub.getAppInstance();\n" + " if(main instanceof PushCallback) {\n" + " return (PushCallback)main;\n" + " }\n" @@ -4586,7 +4586,7 @@ public void usesClassMethod(String cls, String method) { + " if (intent.getStringExtra(\"error\") != null) {\n" + " final String error = intent.getStringExtra(\"error\");\n" + " System.out.println(\"Push handleRegistration() error: \" + error);\n" - + " final " + request.getMainClass() + " main = stub.getAppInstance();\n" + + " final " + appLifecycleClass(request) + " main = stub.getAppInstance();\n" + " if(main instanceof PushCallback) {\n" + " Display.getInstance().callSerially(new Runnable() {\n" + " public void run() {\n" @@ -4603,7 +4603,7 @@ public void usesClassMethod(String cls, String method) { + " Preferences.set(\"push_key\", registration);\n" + " editor.commit();\n" + " com.codename1.impl.android.AndroidImplementation.registerPushOnServer(registration, d(BUILT_BY_USER) + '/' + PACKAGE_NAME, (byte)1, \"\", \"" + request.getPackageName() + "\");\n" - + " final " + request.getMainClass() + " main = stub.getAppInstance();\n" + + " final " + appLifecycleClass(request) + " main = stub.getAppInstance();\n" + " if(main instanceof PushCallback) {\n" + " Display.getInstance().callSerially(new Runnable() {\n" + " public void run() {\n" @@ -4640,7 +4640,7 @@ public void usesClassMethod(String cls, String method) { + " System.out.println(\"Is running: \" + " + request.getMainClass() + "Stub.isRunning());\n" + " if(" + handlePushImmediatelyCheck +") {\n" + " " + request.getMainClass() + "Stub stub = " + request.getMainClass() + "Stub.getInstance();\n" - + " final " + request.getMainClass() + " main = stub.getAppInstance();\n" + + " final " + appLifecycleClass(request) + " main = stub.getAppInstance();\n" + " if(main instanceof PushCallback) {\n" + " Display.getInstance().setProperty(\"pushType\", messageType);\n"; 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 09475b9467d..a592dbed303 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 @@ -140,7 +140,10 @@ static List connectedNodeIds(Context context) { /// Nodes the Data Layer knows about whether or not they are currently connected. private List bondedNodeIds() { if (com.codename1.ui.CN.isEdt()) { - // Never block the EDT; the cached answer is refreshed off it. + // Never block the EDT -- but the cache has to be filled by someone, or an installed + // companion is reported absent forever. Kick off a refresh and answer with what is + // known so far; listeners are notified when it lands. + refreshBondedAsync(); return cachedBonded; } List out = new ArrayList(); @@ -159,6 +162,28 @@ private List bondedNodeIds() { } private volatile List cachedBonded = new ArrayList(); + private volatile boolean refreshingBonded; + + private void refreshBondedAsync() { + if (refreshingBonded) { + return; + } + refreshingBonded = true; + capabilityClient.getCapability("cn1_wearable", CapabilityClient.FILTER_ALL) + .addOnCompleteListener(new com.google.android.gms.tasks.OnCompleteListener() { + public void onComplete(com.google.android.gms.tasks.Task task) { + List out = new ArrayList(); + if (task.isSuccessful() && task.getResult() != null) { + for (Node n : task.getResult().getNodes()) { + out.add(n.getId()); + } + } + cachedBonded = out; + refreshingBonded = false; + WearableConnection.notifyStateChanged(); + } + }); + } public boolean isReachable() { for (Node n : connectedNodes()) { From 4930d724ceb35adf7c4487e557936ec58effaad0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:06:02 +0300 Subject: [PATCH 016/250] Generate the watch skins at build time, and finish the transfer path The skins were never committed: *.skin is gitignored repository-wide, so the four I generated existed only on my machine and the simulator's watch mode would have been dead for everyone else. They are now produced during the javase build by the committed generator, from the iPhoneX skin the same step already fetches -- which is how this repo handles skins anyway. File transfers now survive the round trip on all three platforms. A DataItem's inline payload caps out around 100KB, so Android sends an Asset and the listener resolves it back into a payload; the simulator encodes the bytes rather than writing them raw; iOS already re-encoded on receive. Also from review: BIND_LISTENER needs its own intent filter, because the constraint applied to every action in the filter it shared and nothing would ever have bound; onCapabilityChanged keeps the cache honest when the companion is installed or removed while the device stays connected; the capability cache is refreshed from the EDT rather than only off it; an accepted request that is never answered now reaches replyFailed after a timeout instead of leaking forever; and the simulator's socket validates a frame length before allocating on it, so a corrupt peer cannot take the link thread down with it. Mirrored to the BuildDaemon. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/javase/JavaSEWearableBridge.java | 23 +++- .../codename1/impl/ios/IOSWearableBridge.java | 3 + .../builders/AndroidGradleBuilder.java | 9 +- .../builders/wearable/CN1WearableBridge.java | 103 ++++++++++++++++-- .../wearable/CN1WearableListenerService.java | 17 ++- maven/javase/pom.xml | 21 ++++ 6 files changed, 160 insertions(+), 16 deletions(-) diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java index e99443b0e33..c67b3cfc3cd 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWearableBridge.java @@ -23,6 +23,7 @@ package com.codename1.impl.javase; import com.codename1.wearable.WearableConnection; +import com.codename1.wearable.WearableMessage; import com.codename1.wearable.spi.WearableBridge; import java.io.DataInputStream; @@ -61,6 +62,9 @@ class JavaSEWearableBridge implements WearableBridge { private static final int FRAME_MESSAGE = 1; private static final int FRAME_REPLY = 2; private static final int FRAME_HELLO = 3; + /// Ceiling on a single frame. Generous for any real payload, small enough that a corrupt length + /// cannot exhaust the heap. + private static final int MAX_FRAME_BYTES = 64 * 1024 * 1024; private final File dataDir; private final File portFile; @@ -223,8 +227,14 @@ public String[] getDataPaths() { public void transferFile(String path, String name, byte[] contents) { // The desktop has no background-transfer scheduler worth simulating, and a transfer that - // arrives eventually is indistinguishable from a data write that arrives eventually. - putData(path + "/" + (name == null ? "file" : name), contents); + // arrives eventually is indistinguishable from a data write that arrives eventually. The + // bytes still have to be encoded as a payload, though: the receiving side decodes every + // value as one, and raw file bytes would arrive as a malformed message with no name. + String fileName = name == null ? "file" : name; + WearableMessage wrapper = new WearableMessage(path) + .put("name", fileName) + .put("contents", contents == null ? new byte[0] : contents); + putData(path + "/" + fileName, wrapper.toByteArray()); } // --- rendezvous --------------------------------------------------------- @@ -301,7 +311,14 @@ private void readLoop(Socket s) { int kind = in.readByte(); String path = in.readUTF(); int token = in.readInt(); - byte[] payload = new byte[in.readInt()]; + int length = in.readInt(); + if (length < 0 || length > MAX_FRAME_BYTES) { + // A corrupt or mismatched peer stream. Allocating on this would throw + // NegativeArraySizeException or OutOfMemoryError, neither of which the + // accept/connect loop catches -- it would take the link's thread with it. + throw new IOException("Implausible frame length " + length); + } + byte[] payload = new byte[length]; in.readFully(payload); switch (kind) { case FRAME_MESSAGE: diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableBridge.java index e58a23a9fbd..e1f8e0824a9 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableBridge.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSWearableBridge.java @@ -107,6 +107,9 @@ public String[] getDataPaths() { } public void transferFile(String path, String name, byte[] contents) { + // WCSession moves the file itself, so the bytes go across untouched; the native receive + // side re-encodes them as a WearableMessage carrying name and contents, which is what the + // delivery path decodes. Sending is therefore raw by design, not by omission. nativeInstance.wearableTransferFile(path, name, contents); } } 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 f8e437cb05b..fac826811d8 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 @@ -3296,11 +3296,14 @@ public void usesClassMethod(String cls, String method) { // that would narrow it, so the service validates the source node of every event // instead (see CN1WearableListenerService). " \n" + // BIND_LISTENER is how Play services binds the service, and its intent carries + // no wear: URI -- so it needs a filter of its own. Putting it alongside the + // event actions would apply the constraint to it too and nothing would + // ever bind. + " \n" - // BIND_LISTENER is how Play services binds the service at all; the specific - // actions below narrow what it delivers. Without it nothing binds and no - // callback ever arrives. + " \n" + + " \n" + + " \n" + " \n" + " \n" + " \n" 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 a592dbed303..d66d7de0414 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 @@ -35,9 +35,13 @@ import com.google.android.gms.wearable.DataClient; import com.google.android.gms.wearable.DataItem; import com.google.android.gms.wearable.DataItemBuffer; +import com.google.android.gms.wearable.DataMap; +import com.google.android.gms.wearable.DataMapItem; import com.google.android.gms.wearable.MessageClient; import com.google.android.gms.wearable.Node; import com.google.android.gms.wearable.NodeClient; +import com.google.android.gms.wearable.Asset; +import com.google.android.gms.wearable.PutDataMapRequest; import com.google.android.gms.wearable.PutDataRequest; import com.google.android.gms.wearable.Wearable; @@ -104,6 +108,7 @@ public CN1WearableBridge(Context context) { this.dataClient = Wearable.getDataClient(this.context); this.nodeClient = Wearable.getNodeClient(this.context); this.capabilityClient = Wearable.getCapabilityClient(this.context); + current = this; } // --- state -------------------------------------------------------------- @@ -146,6 +151,9 @@ private List bondedNodeIds() { refreshBondedAsync(); return cachedBonded; } + if (bondedStamp != 0 && System.currentTimeMillis() - bondedStamp <= NODE_CACHE_MILLIS) { + return cachedBonded; + } List out = new ArrayList(); try { CapabilityInfo info = Tasks.await( @@ -158,11 +166,32 @@ private List bondedNodeIds() { // No capability info: fall back to "nothing known". } cachedBonded = out; + bondedStamp = System.currentTimeMillis(); return out; } private volatile List cachedBonded = new ArrayList(); private volatile boolean refreshingBonded; + private volatile long bondedStamp; + + /// Accepts a capability set pushed by Play services, so the cache tracks an install or + /// uninstall that happens while the device stays connected. + static void capabilityChanged(CapabilityInfo info) { + CN1WearableBridge b = current; + if (b == null || info == null) { + return; + } + List out = new ArrayList(); + for (Node n : info.getNodes()) { + out.add(n.getId()); + } + b.cachedBonded = out; + b.bondedStamp = System.currentTimeMillis(); + } + + /// The live bridge, so the listener service can push state into it. The service and the bridge + /// are created independently by Android, which is why this is not a constructor argument. + private static volatile CN1WearableBridge current; private void refreshBondedAsync() { if (refreshingBonded) { @@ -179,6 +208,7 @@ public void onComplete(com.google.android.gms.tasks.Task task) { } } cachedBonded = out; + bondedStamp = System.currentTimeMillis(); refreshingBonded = false; WearableConnection.notifyStateChanged(); } @@ -304,6 +334,10 @@ private void fanOut(String path, byte[] payload, final int replyToken) { return; } if (replyToken != 0) { + // A send can succeed and still never be answered -- an older peer that does not know + // the path, or a cold start Android refused to allow. Without this the pending entry + // lives forever and neither handler method is ever called. + scheduleReplyTimeout(replyToken); // Fail only when NO node accepted the request: one watch failing while another // succeeds must not cancel the handler that the successful one is about to answer. com.google.android.gms.tasks.Tasks.whenAllComplete(tasks).addOnCompleteListener( @@ -366,6 +400,23 @@ private static final class InboundRequest { new HashMap(); private static int nextLocalToken = 1; + /** How long an accepted request may go unanswered before the handler is failed. */ + private static final int REPLY_TIMEOUT_MILLIS = 30000; + + /** + * Fails a pending request that is never answered. {@code deliverReply} removes the token on the + * first call, so a real answer arriving first makes this a no-op. + */ + private void scheduleReplyTimeout(final int replyToken) { + new java.util.Timer(true).schedule(new java.util.TimerTask() { + public void run() { + WearableConnection.deliverReply(replyToken, null, + "The peer did not answer within " + (REPLY_TIMEOUT_MILLIS / 1000) + + " seconds"); + } + }, REPLY_TIMEOUT_MILLIS); + } + private static String slashPrefixed(String path) { return path.startsWith("/") ? path : "/" + path; } @@ -427,14 +478,50 @@ public String[] getDataPaths() { } public void transferFile(String path, String name, byte[] contents) { - // A DataItem already syncs in the background and survives both apps being killed, which is - // the guarantee a file transfer makes. The bytes are the file's own, not a WearableMessage, - // so wrap them in one -- the receiver decodes every DataItem as a payload and would - // otherwise read a PNG as a malformed message. - WearableMessage wrapper = new WearableMessage(path) - .put("name", name == null ? "file" : name) - .put("contents", contents == null ? new byte[0] : contents); - putData(path + "/" + (name == null ? "file" : name), wrapper.toByteArray()); + // A DataItem's inline payload is capped at about 100KB, which a real file routinely + // exceeds; an Asset is the Data Layer's own answer for bulk and is streamed in the + // background. The DataItem carries the name and the Asset, so the receiver still gets a + // WearableMessage rather than raw bytes. + String fileName = name == null ? "file" : name; + byte[] body = contents == null ? new byte[0] : contents; + PutDataMapRequest req = PutDataMapRequest.create(dataPath(path + "/" + fileName)); + req.getDataMap().putString("name", fileName); + req.getDataMap().putAsset("asset", Asset.createFromBytes(body)); + dataClient.putDataItem(req.asPutDataRequest().setUrgent()); + } + + /** + * Rebuilds the {@code WearableMessage} form of a file transfer, or null when the item is an + * ordinary published value rather than a transfer. + * + * @param context any context + * @param item the received data item + * @return the encoded payload, or null + */ + static byte[] decodeTransfer(Context context, DataItem item) { + try { + DataMap map = DataMapItem.fromDataItem(item).getDataMap(); + Asset asset = map.getAsset("asset"); + if (asset == null) { + return null; + } + java.io.InputStream in = Tasks.await( + Wearable.getDataClient(context.getApplicationContext()).getFdForAsset(asset), + TIMEOUT_SECONDS, TimeUnit.SECONDS).getInputStream(); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + in.close(); + return new WearableMessage(item.getUri().getPath()) + .put("name", map.getString("name", "file")) + .put("contents", out.toByteArray()) + .toByteArray(); + } catch (Throwable notATransfer) { + return null; + } } // --- paths -------------------------------------------------------------- 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 75117410e5d..3bd825da775 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 @@ -140,12 +140,25 @@ public void onDataChanged(DataEventBuffer events) { path.substring(CN1WearableBridge.pathPrefix().length())); if (event.getType() == DataEvent.TYPE_DELETED) { WearableConnection.deliverDataRemoved(appPath); - } else { - WearableConnection.deliverDataChanged(appPath, event.getDataItem().getData()); + continue; } + // A file transfer arrives as a DataMap carrying an Asset rather than an inline payload. + // Turn it back into the WearableMessage the receiver expects; this callback already + // runs off the main thread, so resolving the asset here is fine. + byte[] payload = CN1WearableBridge.decodeTransfer(this, event.getDataItem()); + WearableConnection.deliverDataChanged(appPath, + payload != null ? payload : event.getDataItem().getData()); } } + @Override + public void onCapabilityChanged(com.google.android.gms.wearable.CapabilityInfo info) { + // The companion was installed or removed while the device stayed connected. Nothing else + // would notice: the capability cache would keep answering with the previous result. + CN1WearableBridge.capabilityChanged(info); + WearableConnection.notifyStateChanged(); + } + @Override public void onPeerConnected(com.google.android.gms.wearable.Node peer) { WearableConnection.notifyStateChanged(); diff --git a/maven/javase/pom.xml b/maven/javase/pom.xml index 14f9f5f522a..3ce15f02206 100644 --- a/maven/javase/pom.xml +++ b/maven/javase/pom.xml @@ -253,6 +253,27 @@ + + Generating watch skins + + + + + + + + + + " escaped into a visible string. Comments are stripped outside CDATA, left alone inside it, and the search for the closing tag skips them exactly as it skips CDATA -- a written in a comment is no more an end tag than one written in a CDATA section. Plugin suite 493/0; watch golden suite 158 pass / 0 fail / 0 not-run; clang -fsyntax-only clean for watchos/arm64_32. --- Ports/iOSPort/nativeSources/CN1WatchRuntime.m | 80 +++++++++++++++++-- .../com/codename1/builders/IPhoneBuilder.java | 38 ++++++--- .../builders/WatchNativeBuilder.java | 58 +++++++++++--- 3 files changed, 150 insertions(+), 26 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1WatchRuntime.m b/Ports/iOSPort/nativeSources/CN1WatchRuntime.m index b19ab63b649..3c685fce280 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchRuntime.m +++ b/Ports/iOSPort/nativeSources/CN1WatchRuntime.m @@ -40,6 +40,7 @@ #include "java_lang_NullPointerException.h" #include "java_lang_RuntimeException.h" #include +#include #include // Mirror CodenameOne_GLAppDelegate's installSignalHandlers (that file is the @@ -149,6 +150,12 @@ void cn1_watch_runtime_paint(void) { static int cn1WatchPendingPhases[CN1_WATCH_MAX_PENDING_PHASES]; static int cn1WatchPendingPhaseCount = 0; +/// Guards the queue. It used to be touched only from the main thread -- transitions and the paint +/// pump both run there -- but the drain below waits for readiness off that thread. +static pthread_mutex_t cn1WatchPhaseLock = PTHREAD_MUTEX_INITIALIZER; + +static BOOL cn1WatchDrainThreadRunning = NO; + /// Records a phase for later delivery, collapsing a repeat of the one already at the tail. /// /// watchOS can send the same transition twice; delivering it twice would hand the stub a second @@ -200,17 +207,20 @@ static void cn1WatchDeliverPhase(int phase) { /// transition is waiting, so a foreground arriving next has to flush the backlog itself before /// recording anything of its own. static void cn1WatchReplayPendingPhase(void) { - if (cn1WatchPendingPhaseCount == 0 || !cn1WatchJavaReady()) { + if (!cn1WatchJavaReady()) { return; } int pending[CN1_WATCH_MAX_PENDING_PHASES]; - int count = cn1WatchPendingPhaseCount; + int count; + pthread_mutex_lock(&cn1WatchPhaseLock); + count = cn1WatchPendingPhaseCount; for (int i = 0; i < count; i++) { pending[i] = cn1WatchPendingPhases[i]; } - // Cleared BEFORE delivering: a delivery runs translated code, and re-entering this from it must - // not replay what is already on its way. + // Cleared BEFORE delivering, and under the lock: a delivery runs translated code, and neither + // a re-entrant call nor the drain thread must replay what is already on its way. cn1WatchPendingPhaseCount = 0; + pthread_mutex_unlock(&cn1WatchPhaseLock); for (int i = 0; i < count; i++) { cn1WatchDeliverPhase(pending[i]); } @@ -221,10 +231,70 @@ static void cn1WatchReplayPendingPhase(void) { /// A transition is never delivered ahead of an earlier one that is still waiting -- that is what /// turned a background/foreground pair across the readiness boundary into a foreground the stub /// could not balance. +/// Waits for the Java side to come up and then drains the queue. +/// +/// The pump cannot be the only drain. applicationWillResignActive stops it, so a phase queued +/// while the watch is in the background sits there while the VM finishes initialising -- the stub's +/// run() calls start() and nothing delivers the stop() that should follow it, for the whole +/// suspension. Waiting on the readiness transition itself is the trigger that does not depend on +/// something else happening first. +/// +/// A detached thread rather than a timer, because timers are scheduled on a run loop the watch is +/// no longer servicing. It exits as soon as the queue drains or the wait is hopeless. +static void *cn1WatchPhaseDrainThread(void *arg) { + (void)arg; + // 30s at 50ms. Display.init is milliseconds away in practice; the bound exists so a VM that + // never comes up does not leave a thread spinning for the life of the process. + for (int i = 0; i < 600; i++) { + pthread_mutex_lock(&cn1WatchPhaseLock); + BOOL done = cn1WatchPendingPhaseCount == 0; + pthread_mutex_unlock(&cn1WatchPhaseLock); + if (done) { + break; + } + if (cn1WatchJavaReady()) { + cn1WatchReplayPendingPhase(); + break; + } + usleep(50 * 1000); + } + pthread_mutex_lock(&cn1WatchPhaseLock); + cn1WatchDrainThreadRunning = NO; + pthread_mutex_unlock(&cn1WatchPhaseLock); + return NULL; +} + +/// Arms the drain thread, at most one at a time. +static void cn1WatchArmPhaseDrain(void) { + pthread_mutex_lock(&cn1WatchPhaseLock); + BOOL alreadyRunning = cn1WatchDrainThreadRunning; + cn1WatchDrainThreadRunning = YES; + pthread_mutex_unlock(&cn1WatchPhaseLock); + if (alreadyRunning) { + return; + } + pthread_t drain; + if (pthread_create(&drain, NULL, cn1WatchPhaseDrainThread, NULL) == 0) { + pthread_detach(drain); + } else { + pthread_mutex_lock(&cn1WatchPhaseLock); + cn1WatchDrainThreadRunning = NO; + pthread_mutex_unlock(&cn1WatchPhaseLock); + } +} + static void cn1WatchHandlePhase(int phase) { cn1WatchReplayPendingPhase(); - if (!cn1WatchJavaReady() || cn1WatchPendingPhaseCount > 0) { + pthread_mutex_lock(&cn1WatchPhaseLock); + BOOL queued = !cn1WatchJavaReady() || cn1WatchPendingPhaseCount > 0; + if (queued) { cn1WatchQueuePhase(phase); + } + pthread_mutex_unlock(&cn1WatchPhaseLock); + if (queued) { + // Do not wait for the next paint or the next transition: neither is guaranteed to come + // while the watch is suspended, which is exactly when this queue is non-empty. + cn1WatchArmPhaseDrain(); return; } cn1WatchDeliverPhase(phase); 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 6b83e02caa1..81090bb3396 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 @@ -325,13 +325,20 @@ void resolveHealthUsagePerRoot(BuildRequest request, File classesDir) { if (!watchNativeBuilder.needsOwnTranslation()) { return; } + // Rooted at the LIFECYCLE classes, not the generated stubs, and run before either stub is + // written. + // + // A stub is not evidence about the app: it installs the registries the app might need, and + // the health bindings among them reference HealthStore and every listener the app-wide scan + // found. Walking from the stubs therefore made both roots reach health code no matter which + // lifecycle actually used it, which is this whole attribution answering its own question. + // The lifecycle class is the honest root -- it is the code the developer wrote. String pkg = request.getPackageName() == null ? "" : request.getPackageName().replace('.', '/'); String prefix = pkg.length() == 0 ? "" : pkg + "/"; - java.util.List phoneRoots = java.util.Arrays.asList(phoneStubDir, classesDir); - java.util.List watchRoots = java.util.Arrays.asList(watchStubDir, classesDir); - phoneRootReachesHealth = reachesHealth(phoneRoots, prefix + request.getMainClass() + "Stub"); - watchRootReachesHealth = reachesHealth(watchRoots, - prefix + WatchNativeBuilder.translationRoot(request.getMainClass()) + "Stub"); + java.util.List roots = java.util.Arrays.asList(classesDir); + phoneRootReachesHealth = reachesHealth(roots, prefix + request.getMainClass()); + watchRootReachesHealth = reachesHealth(roots, + watchNativeBuilder.getWatchMain().replace('.', '/')); log("[watchNative] HealthKit reachability: phone=" + phoneRootReachesHealth + ", watch=" + watchRootReachesHealth); } @@ -2132,6 +2139,11 @@ public void usesClassMethod(String cls, String method) { // its installGlobal() call into the Stub right before the first // init(Object) so theme.getImage("foo.svg") returns the transcoded // SVG immediately. Skipped silently for apps that have no SVGs. + // Before the stubs are written, because what goes INTO each stub depends on the answer: + // installing the health bindings in a stub whose lifecycle never touches health would both + // entitle that target and make it reach health code, which is the question being asked. + resolveHealthUsagePerRoot(request, classesDir); + String healthBindingsInstall = ""; String healthInstallStatement = HealthListenerBindings .installStatement(healthScan.resolve()); @@ -2139,6 +2151,13 @@ public void usesClassMethod(String cls, String method) { healthBindingsInstall = " " + healthInstallStatement; } + // Per target, because the registry is a hard reference to HealthStore and to every listener + // the app-wide scan found. Installing it in a stub whose lifecycle never touches health + // pulls HealthKit into that slice and entitles it -- release signing then fails against an + // App ID without the capability. A target that does not reach health has no listeners to + // register either, so withholding it costs that slice nothing. + String phoneHealthBindingsInstall = phoneRootReachesHealth ? healthBindingsInstall : ""; + String watchHealthBindingsInstall = watchRootReachesHealth ? healthBindingsInstall : ""; String svgRegistryInstall = ""; File svgRegistryClassFile = new File(classesDir, @@ -2311,7 +2330,7 @@ public void usesClassMethod(String cls, String method) { + " initialized = true;\n" + firebaseRegisterInstall + svgRegistryInstall - + healthBindingsInstall + + phoneHealthBindingsInstall + " i.init(this);\n" + createStartInvocation(request, "i") + " } else {\n" @@ -2367,7 +2386,7 @@ public void usesClassMethod(String cls, String method) { registerNativeImplementationsAndCreateStubs( new URLClassLoader(new URL[]{codenameOneJar.toURI().toURL()}), stubSource, classesDir), - iosMode, svgRegistryInstall, healthBindingsInstall, + iosMode, svgRegistryInstall, watchHealthBindingsInstall, routeDispatcherInstallSource(sourceZip, " "), annotationFrameworksInstallSource(sourceZip, " ")); } @@ -2651,11 +2670,6 @@ public void usesClassMethod(String cls, String method) { try { phoneStubDir = watchNativeBuilder.isolateStub(request, classesDir, tmpFile, false); watchStubDir = watchNativeBuilder.isolateStub(request, classesDir, tmpFile, true); - // Now that each root has a stub of its own, ask which of them actually reaches the - // health API. Must happen here: the entitlement decision runs long before the - // translator, whose trees would answer the same question, because the capability it - // injects has to be in the request by the time the Info.plist is generated. - resolveHealthUsagePerRoot(request, classesDir); } catch (IOException ex) { throw new BuildException("Failed to separate the phone and watch entry points", ex); } 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 3155bd70d50..91422bd1c52 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 @@ -776,21 +776,61 @@ private static int closeOfString(String inject, int from) { if (close < 0) { return -1; } - int cdata = inject.indexOf(" close) { + int cdata = inject.indexOf(CDATA_OPEN, i); + int comment = inject.indexOf(COMMENT_OPEN, i); + // Whichever construct starts first, if either starts before the candidate end tag. + // A written inside a comment is no more an end tag than one inside CDATA. + boolean cdataFirst = cdata >= 0 && (comment < 0 || cdata < comment); + int skipFrom = cdataFirst ? cdata : comment; + if (skipFrom < 0 || skipFrom > close) { return close; } - int end = inject.indexOf("]]>", cdata + CDATA_OPEN.length()); + String opener = cdataFirst ? CDATA_OPEN : COMMENT_OPEN; + String closer = cdataFirst ? CDATA_CLOSE : COMMENT_CLOSE; + int end = inject.indexOf(closer, skipFrom + opener.length()); if (end < 0) { - // Unterminated CDATA. Nothing after it can be located reliably, so the key is - // treated as absent rather than guessed at. + // Unterminated. Nothing after it can be located reliably, so the key is treated as + // absent rather than guessed at. return -1; } - i = end + CDATA_CLOSE.length(); + i = end + closer.length(); } return -1; } + private static final String COMMENT_OPEN = ""; + + /// Removes XML comments from text outside any CDATA section. + /// + /// A comment is markup, not content: the parser reading the phone's plist drops it, so keeping + /// it here escaped "" into the watch's visible string. Inside CDATA the same + /// characters are data and are left exactly as written. + private static String stripComments(String value) { + if (value == null || value.indexOf(COMMENT_OPEN) < 0) { + return value; + } + StringBuilder out = new StringBuilder(value.length()); + int i = 0; + while (i < value.length()) { + int open = value.indexOf(COMMENT_OPEN, i); + if (open < 0) { + out.append(value.substring(i)); + break; + } + out.append(value, i, open); + int close = value.indexOf(COMMENT_CLOSE, open + COMMENT_OPEN.length()); + if (close < 0) { + // Unterminated: everything after it is inside the comment, so nothing more is + // content. + break; + } + i = close + COMMENT_CLOSE.length(); + } + return out.toString(); + } + private static final String CDATA_OPEN = ""; @@ -811,17 +851,17 @@ static String plistStringContent(String raw) { return null; } if (raw.indexOf(CDATA_OPEN) < 0) { - return decodeXmlEntities(raw.trim()); + return decodeXmlEntities(stripComments(raw).trim()); } StringBuilder out = new StringBuilder(raw.length()); int i = 0; while (i < raw.length()) { int cdata = raw.indexOf(CDATA_OPEN, i); if (cdata < 0) { - out.append(decodeXmlEntities(raw.substring(i))); + out.append(decodeXmlEntities(stripComments(raw.substring(i)))); break; } - out.append(decodeXmlEntities(raw.substring(i, cdata))); + out.append(decodeXmlEntities(stripComments(raw.substring(i, cdata)))); int body = cdata + CDATA_OPEN.length(); int end = raw.indexOf(CDATA_CLOSE, body); if (end < 0) { From 49aa712f2fee36cfad232b8dd70210e33e52c94a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:17:53 +0700 Subject: [PATCH 212/250] Wearables: root the phone health walk at the lifecycle the test executor runs generateUnitTestFiles replaces the main class with CodenameOneUnitTestExecutor before the reachability walk runs, and that generated class is not compiled into classesDir at that point. The walk therefore found no root at all, concluded the phone does not reach health, and stripped both the HealthKit entitlement and the generated listener registry from a unit-test build whose executor instantiates and runs the very lifecycle that uses HealthKit. The original class is captured before the swap and used as the phone root. The daemon already kept it in a field; the client side only had it as a local inside generateUnitTestFiles, so it now records it the same way. Plugin suite 493/0. --- .../com/codename1/builders/IPhoneBuilder.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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 81090bb3396..e367cdf94d7 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 @@ -68,6 +68,9 @@ public class IPhoneBuilder extends Executor { /// Where each entry-point stub lives once they have been separated, or null when there is one /// translation and the classpath is untouched. See WatchNativeBuilder.isolateStub. + /// The lifecycle class the project declared, before a unit-test build swaps it out. + private String origMainClass; + private File phoneStubDir; private File watchStubDir; @@ -336,7 +339,14 @@ void resolveHealthUsagePerRoot(BuildRequest request, File classesDir) { String pkg = request.getPackageName() == null ? "" : request.getPackageName().replace('.', '/'); String prefix = pkg.length() == 0 ? "" : pkg + "/"; java.util.List roots = java.util.Arrays.asList(classesDir); - phoneRootReachesHealth = reachesHealth(roots, prefix + request.getMainClass()); + // origMainClass, not request.getMainClass(). generateUnitTestFiles has already swapped the + // main class for CodenameOneUnitTestExecutor, which is not compiled into classesDir yet -- + // so the walk found no root at all, answered "does not reach health", and stripped the + // phone's entitlement and its listener registry from a test build whose executor runs the + // very lifecycle that uses HealthKit. + String phoneRoot = origMainClass != null && origMainClass.length() > 0 + ? origMainClass : request.getMainClass(); + phoneRootReachesHealth = reachesHealth(roots, prefix + phoneRoot); watchRootReachesHealth = reachesHealth(roots, watchNativeBuilder.getWatchMain().replace('.', '/')); log("[watchNative] HealthKit reachability: phone=" + phoneRootReachesHealth @@ -2119,6 +2129,11 @@ public void usesClassMethod(String cls, String method) { "Shows your location on the map."); } } + // Captured BEFORE generateUnitTestFiles, which replaces the main class with + // CodenameOneUnitTestExecutor. Anything downstream that needs the lifecycle the developer + // actually wrote -- the health reachability walk below is one -- cannot get it from the + // request afterwards, and the executor class is not compiled into classesDir yet either. + origMainClass = request.getMainClass(); try { generateUnitTestFiles(request, stubSource); } catch (Exception ex) { From 6a75586461c50411c6eeabe8b4b8b1020995f50b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:50:45 +0700 Subject: [PATCH 213/250] Wearables: watch skins carry coordinate maps, and each root binds its own listeners Three review findings, all of them cases where something the watch needed was skipped because the code that provides it only looked at the phone. The generated watch skins shipped no skin_map.png. JavaSEPort reads the screen rectangle and the bezel hotspots out of that map and only the round branch skips it, so every non-round watch skin -- both Apple Watch sizes and Wear Square -- threw a NullPointerException out of initializeCoordinates the moment it was selected. Wear Round kept working, which is why it went unnoticed. The generator now emits both maps for every skin, and the loader falls back to the declared display geometry instead of dereferencing a null map, so a third-party skin without one is no longer a crash. The health listener factory names every listener in a `new` expression, which the translator follows. Installing the app-wide factory in both stubs therefore dragged each target's listeners, and their whole transitive graph, into the other target's binary. Each root now gets a factory of its own, holding only the listeners it reaches. Swift package products ride on product_ref, not file_ref, so the framework mirroring loop passed straight over them: a project declaring ios.spm.packages linked them into the phone and not the watch, and a watch lifecycle calling into one failed to link with nothing naming the missing dependency. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/impl/javase/JavaSEPort.java | 12 ++ .../builders/HealthListenerBindings.java | 46 +++++- .../com/codename1/builders/IPhoneBuilder.java | 136 ++++++++++++---- .../builders/WatchNativeBuilder.java | 41 +++++ .../builders/HealthListenerBindingsTest.java | 39 +++++ .../builders/HealthScannerParityTest.java | 11 +- .../IPhoneBuilderHealthListenerScopeTest.java | 121 ++++++++++++++ .../builders/WatchNativeBuilderTest.java | 32 ++++ .../javase/WatchSkinCoordinateMapTest.java | 149 ++++++++++++++++++ tools/watch-skins/GenerateWatchSkins.java | 35 +++- 10 files changed, 582 insertions(+), 40 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java create mode 100644 maven/javase/src/test/java/com/codename1/impl/javase/WatchSkinCoordinateMapTest.java diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 549ef1f766a..130547c3093 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -4358,6 +4358,18 @@ public boolean hasSkins() { } private void initializeCoordinates(BufferedImage map, Properties props, Map coordinates, java.awt.Rectangle screenPosition) { + if (map == null) { + // A skin with no coordinate map. The map is where the screen rectangle and the bezel + // hotspots normally come from, so the only thing left to believe is what the skin + // declares -- and a skin that declares its display geometry has said everything this + // method would have inferred. Without this the loader died on a NullPointerException + // one line down, which reads as a corrupt skin rather than a missing optional file. + screenPosition.x = Integer.parseInt(props.getProperty("displayX", "0")); + screenPosition.y = Integer.parseInt(props.getProperty("displayY", "0")); + screenPosition.width = Integer.parseInt(props.getProperty("displayWidth", "0")); + screenPosition.height = Integer.parseInt(props.getProperty("displayHeight", "0")); + return; + } int[] buffer = new int[map.getWidth() * map.getHeight()]; map.getRGB(0, 0, map.getWidth(), map.getHeight(), buffer, 0, map.getWidth()); int screenX1 = Integer.MAX_VALUE; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerBindings.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerBindings.java index f65b28c89e5..b8479a6b774 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerBindings.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerBindings.java @@ -58,6 +58,16 @@ final class HealthListenerBindings { static final String CLASS_NAME = "CN1HealthListenerBindings"; static final String FQCN = PACKAGE + "." + CLASS_NAME; + /// Suffix of the factory generated for a separate watch translation root. + /// + /// A second entry point gets a factory of its OWN rather than sharing the phone's. The + /// factory names every listener in a `new` expression, which is a hard reference the + /// translator follows, so one shared factory drags every phone-only listener -- and whatever + /// those listeners reach -- into a watch binary that can never be asked for them, and the + /// reverse. Two factories, each holding only what its own root reaches, is what actually lets + /// the two translations be shaken down independently. + static final String WATCH_SUFFIX = "Watch"; + private HealthListenerBindings() { } @@ -72,9 +82,21 @@ private HealthListenerBindings() { * {@code HealthBackgroundListener} */ static String generate(Map sourceByBinaryName) { + return generate(sourceByBinaryName, ""); + } + + /** + * The same, for one named translation root. {@code classSuffix} is + * appended to the class name so a second root can carry a factory + * holding only the listeners it reaches; pass {@code ""} for the single + * translation every ordinary build produces. + */ + static String generate(Map sourceByBinaryName, + String classSuffix) { if (sourceByBinaryName == null || sourceByBinaryName.isEmpty()) { return null; } + String className = CLASS_NAME + suffix(classSuffix); List sorted = new ArrayList(sourceByBinaryName.keySet()); Collections.sort(sorted); @@ -94,11 +116,11 @@ static String generate(Map sourceByBinaryName) { .append(" the classes\n"); sb.append(" * survive shrinking and obfuscation. Do not edit.\n"); sb.append(" */\n"); - sb.append("public final class ").append(CLASS_NAME) + sb.append("public final class ").append(className) .append(" implements HealthBackgroundListenerFactory {\n\n"); sb.append(" public static void install() {\n"); sb.append(" HealthStore.setBackgroundListenerFactory(new ") - .append(CLASS_NAME).append("());\n"); + .append(className).append("());\n"); sb.append(" }\n\n"); sb.append(" public HealthBackgroundListener create(String") .append(" className) {\n"); @@ -139,14 +161,30 @@ static String generate(Map sourceByBinaryName) { * {@code null} when nothing was generated. */ static String installStatement(Map sourceByBinaryName) { + return installStatement(sourceByBinaryName, ""); + } + + /** The install statement for the factory of one named translation root. */ + static String installStatement(Map sourceByBinaryName, + String classSuffix) { if (sourceByBinaryName == null || sourceByBinaryName.isEmpty()) { return null; } - return FQCN + ".install();\n"; + return FQCN + suffix(classSuffix) + ".install();\n"; } /** The path the generated source is written to, relative to a source root. */ static String sourcePath() { - return PACKAGE.replace('.', '/') + "/" + CLASS_NAME + ".java"; + return sourcePath(""); + } + + /** The path for the factory of one named translation root. */ + static String sourcePath(String classSuffix) { + return PACKAGE.replace('.', '/') + "/" + CLASS_NAME + + suffix(classSuffix) + ".java"; + } + + private static String suffix(String classSuffix) { + return classSuffix == null ? "" : classSuffix; } } 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 e367cdf94d7..e823a16c8d7 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 @@ -165,6 +165,22 @@ private static String trimToNull(String v) { private boolean watchRootReachesHealth = true; + /// What each root reaches, kept so the listener registry can be filtered by it too. + /// + /// Null means one translation, and null is what tells every consumer below to filter nothing: + /// with a single root the app-wide answer and the per-root answer are the same question. + private java.util.Set phoneReachableClasses; + + private java.util.Set watchReachableClasses; + + /// The listener bindings each root gets, resolved once where the stubs are written and read + /// again where the factory sources are generated. + private java.util.Map phoneHealthListeners = + java.util.Collections.emptyMap(); + + private java.util.Map watchHealthListeners = + java.util.Collections.emptyMap(); + /// Internal names of every class reachable from a root, following constant-pool class /// references. /// @@ -298,8 +314,8 @@ private java.util.List classReferences(File classFile) { } /// Whether the class graph rooted here reaches the health API at all. - private boolean reachesHealth(java.util.List roots, String rootInternal) { - for (String name : reachableClasses(roots, rootInternal)) { + private boolean reachesHealth(java.util.Set reachable) { + for (String name : reachable) { // The SAME distinction the scanner draws, and for the same reason. The umbrella // package is not evidence of HealthKit: com.codename1.health.sensors is pure BLE, and // the shared model types (a HealthSample, a QuantitySample) are named by sensor code @@ -346,13 +362,73 @@ void resolveHealthUsagePerRoot(BuildRequest request, File classesDir) { // very lifecycle that uses HealthKit. String phoneRoot = origMainClass != null && origMainClass.length() > 0 ? origMainClass : request.getMainClass(); - phoneRootReachesHealth = reachesHealth(roots, prefix + phoneRoot); - watchRootReachesHealth = reachesHealth(roots, + phoneReachableClasses = reachableClasses(roots, prefix + phoneRoot); + watchReachableClasses = reachableClasses(roots, watchNativeBuilder.getWatchMain().replace('.', '/')); + phoneRootReachesHealth = reachesHealth(phoneReachableClasses); + watchRootReachesHealth = reachesHealth(watchReachableClasses); log("[watchNative] HealthKit reachability: phone=" + phoneRootReachesHealth + ", watch=" + watchRootReachesHealth); } + /// The listener bindings one translation root can actually reach. + /// + /// The scan that produced `all` walked the whole classes directory, so it answers "this app + /// declares these background listeners". The generated factory names every one of them in a + /// `new` expression, and that is a reference the translator follows -- so handing the app-wide + /// map to both roots pulls each target's listeners, and their whole transitive graph, into the + /// other target's binary. A watch that can never be relaunched for the phone's listener has no + /// business carrying it. + /// + /// Dropping a listener the root DOES need would be the dangerous direction, and cannot happen + /// here: `subscribe(request, MyListener.class)` puts the listener in its caller's constant pool, + /// and this walk is a strict over-approximation of what the translator keeps -- anything that + /// survives translation was reached by the walk first. + /// Writes one root's generated factory, if that root binds anything. + private void writeHealthBindings(File stubSource, + java.util.Map listeners, String classSuffix) + throws BuildException { + String source = HealthListenerBindings.generate(listeners, classSuffix); + if (source == null) { + return; + } + File healthBindingsFile = new File(stubSource, + HealthListenerBindings.sourcePath(classSuffix)); + healthBindingsFile.getParentFile().mkdirs(); + try (OutputStream bindings = new FileOutputStream(healthBindingsFile)) { + bindings.write(source.getBytes("UTF-8")); + } catch (Exception ex) { + throw new BuildException( + "Failed to write the health listener bindings", ex); + } + log("Generated health background-listener bindings for " + + listeners.keySet()); + } + + /// The indented install statement for one root's factory, or "" when that root has no + /// listeners to bind. + private String healthBindingsInstall(java.util.Map listeners, + String classSuffix) { + String statement = HealthListenerBindings.installStatement(listeners, classSuffix); + return statement == null ? "" : " " + statement; + } + + java.util.Map healthListenersReachableFrom( + java.util.Map all, java.util.Set reachable) { + if (reachable == null || all == null || all.isEmpty()) { + return all; + } + java.util.Map out = new java.util.TreeMap(); + for (java.util.Map.Entry e : all.entrySet()) { + // Binary name to internal name. Only the dots separating packages become slashes; a + // nested class keeps its dollar, which is exactly what the walk recorded. + if (reachable.contains(e.getKey().replace('.', '/'))) { + out.put(e.getKey(), e.getValue()); + } + } + return out; + } + boolean phoneUsesHealthData(BuildRequest request) { // The scan is app-wide; the entitlement is per target. A distinct watchMain means the flags // can describe code only the WATCH contains, and entitling the phone for it fails release @@ -2159,20 +2235,23 @@ public void usesClassMethod(String cls, String method) { // entitle that target and make it reach health code, which is the question being asked. resolveHealthUsagePerRoot(request, classesDir); - String healthBindingsInstall = ""; - String healthInstallStatement = HealthListenerBindings - .installStatement(healthScan.resolve()); - if (healthInstallStatement != null) { - healthBindingsInstall = " " - + healthInstallStatement; - } // Per target, because the registry is a hard reference to HealthStore and to every listener - // the app-wide scan found. Installing it in a stub whose lifecycle never touches health - // pulls HealthKit into that slice and entitles it -- release signing then fails against an - // App ID without the capability. A target that does not reach health has no listeners to - // register either, so withholding it costs that slice nothing. - String phoneHealthBindingsInstall = phoneRootReachesHealth ? healthBindingsInstall : ""; - String watchHealthBindingsInstall = watchRootReachesHealth ? healthBindingsInstall : ""; + // it names. Installing it in a stub whose lifecycle never touches health pulls HealthKit + // into that slice and entitles it -- release signing then fails against an App ID without + // the capability -- and installing the app-wide registry in BOTH stubs drags each target's + // listeners into the other. So each root gets a registry of its own, holding only the + // listeners that root reaches, and gets it only if it reaches health at all. + phoneHealthListeners = phoneRootReachesHealth + ? healthListenersReachableFrom(healthScan.resolve(), phoneReachableClasses) + : java.util.Collections.emptyMap(); + watchHealthListeners = watchRootReachesHealth + ? healthListenersReachableFrom(healthScan.resolve(), watchReachableClasses) + : java.util.Collections.emptyMap(); + String phoneHealthBindingsInstall = healthBindingsInstall(phoneHealthListeners, ""); + String watchHealthBindingsInstall = watchNativeBuilder.needsOwnTranslation() + ? healthBindingsInstall(watchHealthListeners, + HealthListenerBindings.WATCH_SUFFIX) + : ""; String svgRegistryInstall = ""; File svgRegistryClassFile = new File(classesDir, @@ -2645,21 +2724,14 @@ public void usesClassMethod(String cls, String method) { for (String warning : healthScan.warnings()) { log("WARNING: " + warning); } - String healthBindingsSource = - HealthListenerBindings.generate(healthScan.resolve()); - if (healthBindingsSource != null) { - File healthBindingsFile = new File(stubSource, - HealthListenerBindings.sourcePath()); - healthBindingsFile.getParentFile().mkdirs(); - try (OutputStream bindings = - new FileOutputStream(healthBindingsFile)) { - bindings.write(healthBindingsSource.getBytes("UTF-8")); - } catch (Exception ex) { - throw new BuildException( - "Failed to write the health listener bindings", ex); - } - log("Generated health background-listener bindings for " - + healthScan.resolve().keySet()); + // One factory per translation root, each holding only the listeners that root reaches -- + // see healthListenersReachableFrom. The two are written into the same source folder because + // one javac pass compiles both stubs; it is the TRANSLATION that separates them, and each + // stub installs only its own. + writeHealthBindings(stubSource, phoneHealthListeners, ""); + if (watchNativeBuilder.needsOwnTranslation()) { + writeHealthBindings(stubSource, watchHealthListeners, + HealthListenerBindings.WATCH_SUFFIX); } String javacPath = System.getProperty("java.home") + "/../bin/javac"; if (!new File(javacPath).exists()) { 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 91422bd1c52..a22cec79c76 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 @@ -1750,6 +1750,47 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, .append(" end\n") .append("end\n"); + // Swift Package Manager products, which the loop above cannot see. + // + // A build file for a package product carries a `product_ref` and NO `file_ref`, so every + // `next unless ref && ref.path` above skips it silently -- a project declaring + // ios.swiftPackages got its packages linked into the phone and not the watch, and a watch + // lifecycle calling into one failed to link with undefined symbols and nothing naming the + // cause. The product dependency also has to be listed on the target itself, not only in the + // frameworks phase, or Xcode does not resolve it for that target at all. + // + // A dependency object of its own per target, rather than the phone's shared between both: + // that is what Xcode itself writes, and a target's package_product_dependencies is + // conceptually its own list even though the pbxproj format would tolerate one object in + // two. + // + // No SDK check is possible here, unlike the system frameworks above. A package declares its + // supported platforms in its own Package.swift, which is not resolved until xcodebuild runs. + // So this mirrors the declaration and says so: a package with no watchOS support fails with + // Xcode naming the product, which is a better outcome than a link error naming nothing. + s.append("app_target.package_product_dependencies.to_a.each do |dep|\n") + .append(" name = dep.respond_to?(:product_name) ? dep.product_name : nil\n") + .append(" next unless name\n") + .append(" next if watch_target.package_product_dependencies.any? { |d| " + + "d.respond_to?(:product_name) && d.product_name == name }\n") + .append(" mirrored = xcproj.new(" + + "Xcodeproj::Project::Object::XCSwiftPackageProductDependency)\n") + .append(" mirrored.package = dep.package if dep.respond_to?(:package)\n") + .append(" mirrored.product_name = name\n") + .append(" watch_target.package_product_dependencies << mirrored\n") + .append(" linked = watch_target.frameworks_build_phase.files.to_a.any? { |bf| " + + "bf.respond_to?(:product_ref) && bf.product_ref && " + + "bf.product_ref.respond_to?(:product_name) && " + + "bf.product_ref.product_name == name }\n") + .append(" unless linked\n") + .append(" pbf = xcproj.new(Xcodeproj::Project::Object::PBXBuildFile)\n") + .append(" pbf.product_ref = mirrored\n") + .append(" watch_target.frameworks_build_phase.files << pbf\n") + .append(" end\n") + .append(" puts \"[watchNative] linking Swift package product #{name} into the " + + "watch target; scope the package to iOS if it has no watchOS support\"\n") + .append("end\n"); + // Mirror the iOS app's bundle resources into the watch target. The CN1 // runtime loads its theme + assets from the app bundle at runtime // (Resources.open(\"/iOS7Theme.res\"), the app theme.res / CN1Resource.res, diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthListenerBindingsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthListenerBindingsTest.java index 5d1e03ae369..1be3b91ff83 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthListenerBindingsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthListenerBindingsTest.java @@ -134,6 +134,45 @@ void sourcePathMatchesThePackage() { HealthListenerBindings.sourcePath()); } + /** + * A second translation root gets a factory of its own. + * + *

The factory names every listener in a {@code new} expression, which the translator + * follows, so one shared factory drags each target's listeners into the other target's binary + * -- the watch carrying the phone's health graph it can never be asked for. Distinct class + * name, distinct file, distinct install statement: the two are compiled by the same javac pass + * and separated only by which stub installs which.

+ */ + @Test + void aSecondRootGetsAFactoryOfItsOwn() { + String suffix = HealthListenerBindings.WATCH_SUFFIX; + String src = HealthListenerBindings.generate( + list("com.example.WristWatcher"), suffix); + assertNotNull(src); + assertTrue(src.contains("public final class CN1HealthListenerBindings" + + suffix + " implements"), + "the watch factory must not collide with the phone's: " + src); + assertTrue(src.contains("new CN1HealthListenerBindings" + suffix + "()"), + "and must install ITSELF, not the phone's factory: " + src); + assertTrue(src.contains("new com.example.WristWatcher()"), src); + + assertEquals(HealthListenerBindings.FQCN + suffix + ".install();\n", + HealthListenerBindings.installStatement( + list("com.example.WristWatcher"), suffix)); + assertEquals("com/codename1/health/generated/CN1HealthListenerBindings" + + suffix + ".java", + HealthListenerBindings.sourcePath(suffix)); + } + + /** No suffix is the single-translation case, unchanged. */ + @Test + void theUnsuffixedFormIsTheSingleTranslationCase() { + assertEquals(HealthListenerBindings.generate(list("com.example.StepWatcher")), + HealthListenerBindings.generate(list("com.example.StepWatcher"), "")); + assertEquals(HealthListenerBindings.sourcePath(), + HealthListenerBindings.sourcePath("")); + } + /** * A nested listener is the common case -- developers put it inside the * class that subscribes. The persisted key must stay the binary name, diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthScannerParityTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthScannerParityTest.java index 420a1a9cb71..686668c5d9b 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthScannerParityTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthScannerParityTest.java @@ -242,9 +242,16 @@ void bothBuildersGenerateListenerBindings() throws Exception { builder + " must report constructibility, or an" + " abstract declarer gets bound instead of" + " its usable subclass"); - assertTrue(src.contains( - "HealthListenerBindings.generate(healthScan.resolve())"), + assertTrue(src.contains("HealthListenerBindings.generate("), builder + " must generate from the resolved set"); + // Android has one artifact and hands the resolved set straight over. iOS may have two + // translation roots, and then each gets its own factory holding only the listeners + // that root reaches -- but the input is still the same resolved set, narrowed rather + // than recomputed. Asserting the exact call text would have said "iOS must not do + // per-root filtering", which is the opposite of what this parity check is for. + assertTrue(src.contains("healthScan.resolve()"), + builder + " must feed the generator from the resolved set, not a" + + " separately derived one"); assertTrue(src.contains("installStatement("), builder + " must install them at startup"); } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java new file mode 100644 index 00000000000..9d300453b72 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java @@ -0,0 +1,121 @@ +/* + * 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.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Which health background listeners each translation root is allowed to bind. + * + *

The listener scan walks the whole classes directory, so it answers "this app declares these + * listeners". The generated factory names every one of them in a {@code new} expression -- a hard + * reference the translator follows -- so handing the app-wide answer to both roots pulls each + * target's listeners, and everything they reach, into the other target's binary. A watch that can + * never be relaunched for the phone's listener has no business carrying it, and vice versa.

+ */ +class IPhoneBuilderHealthListenerScopeTest { + + private static Map listeners(String... binaryNames) { + Map out = new LinkedHashMap(); + for (String n : binaryNames) { + out.put(n, n); + } + return out; + } + + private static Set reachable(String... internalNames) { + Set out = new HashSet(); + for (String n : internalNames) { + out.add(n); + } + return out; + } + + @Test + void eachRootBindsOnlyTheListenersItReaches() { + IPhoneBuilder builder = new IPhoneBuilder(); + Map all = + listeners("com.acme.PhoneWatcher", "com.acme.WristWatcher"); + + Map phone = builder.healthListenersReachableFrom(all, + reachable("com/acme/MyApp", "com/acme/PhoneWatcher")); + assertEquals(1, phone.size(), "the phone must not carry the watch's listener"); + assertTrue(phone.containsKey("com.acme.PhoneWatcher")); + + Map watch = builder.healthListenersReachableFrom(all, + reachable("com/acme/WatchApp", "com/acme/WristWatcher")); + assertEquals(1, watch.size(), "and the watch must not carry the phone's"); + assertTrue(watch.containsKey("com.acme.WristWatcher")); + } + + /** + * A nested listener is the common shape -- developers put it inside the class that subscribes. + * Its binary name keeps the dollar, and only the package dots become slashes; translating the + * dollar too would make it match nothing and silently drop a listener the root does need. + */ + @Test + void aNestedListenerMatchesItsInternalName() { + IPhoneBuilder builder = new IPhoneBuilder(); + Map all = new LinkedHashMap(); + all.put("com.acme.Steps$Watcher", "com.acme.Steps.Watcher"); + + Map phone = builder.healthListenersReachableFrom(all, + reachable("com/acme/Steps$Watcher")); + assertEquals("com.acme.Steps.Watcher", phone.get("com.acme.Steps$Watcher"), + "the source name has to survive the filter, it is what the factory calls new on"); + } + + /** + * One translation is the ordinary build and must be untouched: with a single root the app-wide + * answer and the per-root answer are the same question, and a null reachable set is how that + * is said. + */ + @Test + void oneTranslationFiltersNothing() { + IPhoneBuilder builder = new IPhoneBuilder(); + Map all = listeners("com.acme.PhoneWatcher"); + assertSame(all, builder.healthListenersReachableFrom(all, null)); + } + + /** A root that reaches no listener binds none, rather than falling back to all of them. */ + @Test + void aRootThatReachesNoListenerBindsNone() { + IPhoneBuilder builder = new IPhoneBuilder(); + Map all = listeners("com.acme.PhoneWatcher"); + assertTrue(builder.healthListenersReachableFrom(all, reachable("com/acme/WatchApp")) + .isEmpty()); + assertFalse(HealthListenerBindings.generate( + builder.healthListenersReachableFrom(all, reachable("com/acme/WatchApp"))) != null, + "and generates no factory at all, so nothing references HealthStore either"); + } +} 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 0182cb74a72..c31550a1d2d 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 @@ -672,6 +672,38 @@ void asharedWatchMainKeepsTheSingleTranslation(@TempDir Path tmp) throws Excepti "one binary cannot define main twice: " + ruby); } + /** + * Swift Package Manager products reach the watch target too. + * + *

A build file for a package product carries a {@code product_ref} and no {@code file_ref}, + * so the framework-mirroring loop -- which skips anything without a path -- passed straight + * over them. A project declaring ios.spm.packages linked them into the phone and not the + * watch, and a watch lifecycle calling into one failed at link time with undefined symbols and + * nothing in the build naming the missing dependency.

+ */ + @Test + void swiftPackageProductsReachTheWatchTarget(@TempDir Path tmp) throws Exception { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + String ruby = parse(req).buildXcodeScript(req, tmp.toFile(), "1.0", + java.util.Collections.emptyList()); + + assertTrue(ruby.contains("app_target.package_product_dependencies.to_a.each"), + "the phone's package products are the source of the mirror: " + ruby); + // Both halves are required. Listing the product on the target is what makes Xcode resolve + // the package for it; the frameworks-phase build file is what links it. Either alone + // produces a project that still fails, and differently. + assertTrue(ruby.contains("watch_target.package_product_dependencies << mirrored"), ruby); + assertTrue(ruby.contains("pbf.product_ref = mirrored"), ruby); + assertTrue(ruby.contains("watch_target.frameworks_build_phase.files << pbf"), ruby); + // A dependency object per target, as Xcode itself writes -- not the phone's object listed + // under two targets. + assertTrue(ruby.contains( + "Xcodeproj::Project::Object::XCSwiftPackageProductDependency"), ruby); + // Re-running the generator must not add the product twice. + assertTrue(ruby.contains("next if watch_target.package_product_dependencies.any?"), ruby); + } + /// The bootstrap has to call the stub that actually exists in the watch binary. @Test void theBootstrapEntersTheWatchStub(@TempDir Path tmp) throws Exception { diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/WatchSkinCoordinateMapTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/WatchSkinCoordinateMapTest.java new file mode 100644 index 00000000000..29c3ea0d0be --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/WatchSkinCoordinateMapTest.java @@ -0,0 +1,149 @@ +/* + * 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.javase; + +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.InputStream; +import java.util.Properties; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards the coordinate maps in the generated watch skins. + * + *

{@code JavaSEPort.loadSkinFile} reads the screen rectangle and the bezel hotspots out of a + * companion map image, and only the round branch skips it. The watch skins shipped without one, so + * selecting any non-round watch skin -- both Apple Watch sizes and Wear Square -- threw a + * NullPointerException out of {@code initializeCoordinates} before a form was ever shown. The map + * is easy to leave out again, because the round skin keeps working when it is missing.

+ */ +public class WatchSkinCoordinateMapTest { + + /** Every skin GenerateWatchSkins emits, round or not. */ + private static final String[] SKINS = { + "AppleWatch41mm.skin", "AppleWatch45mm.skin", "WearRound.skin", "WearSquare.skin" + }; + + @Test + public void everyWatchSkinCarriesCoordinateMapsMatchingItsDeclaredDisplay() throws Exception { + for (String skinName : SKINS) { + File skinFile = locate(skinName); + ZipFile zip = new ZipFile(skinFile); + try { + Properties props = new Properties(); + InputStream propsIn = zip.getInputStream(entry(zip, skinName, "skin.properties")); + try { + props.load(propsIn); + } finally { + propsIn.close(); + } + BufferedImage skin = read(zip, skinName, "skin.png"); + for (String mapName : new String[] {"skin_map.png", "skin_map_l.png"}) { + BufferedImage map = read(zip, skinName, mapName); + // Same size as the artwork: the map's pixel coordinates ARE the skin's, so a + // map of a different size reports a screen rectangle in the wrong space. + assertEquals(skin.getWidth(), map.getWidth(), + skinName + " " + mapName + " width must match skin.png"); + assertEquals(skin.getHeight(), map.getHeight(), + skinName + " " + mapName + " height must match skin.png"); + assertDisplayRegion(skinName + " " + mapName, map, props); + } + } finally { + zip.close(); + } + } + } + + /** + * The black region of the map has to be exactly the display the properties declare -- that + * rectangle is what the simulator draws the app into, and a map disagreeing with the + * properties puts the safe-area insets against different pixels than the frame. + */ + private void assertDisplayRegion(String what, BufferedImage map, Properties props) { + int w = map.getWidth(); + int h = map.getHeight(); + int[] buffer = new int[w * h]; + map.getRGB(0, 0, w, h, buffer, 0, w); + int x1 = Integer.MAX_VALUE; + int y1 = Integer.MAX_VALUE; + int x2 = -1; + int y2 = -1; + for (int i = 0; i < buffer.length; i++) { + if (buffer[i] != 0xff000000) { + continue; + } + int x = i % w; + int y = i / w; + x1 = Math.min(x1, x); + y1 = Math.min(y1, y); + x2 = Math.max(x2, x); + y2 = Math.max(y2, y); + } + assertTrue(x2 >= 0, what + " has no black display region; the loader would report a " + + "zero-sized screen"); + assertEquals(Integer.parseInt(props.getProperty("displayX")), x1, what + " displayX"); + assertEquals(Integer.parseInt(props.getProperty("displayY")), y1, what + " displayY"); + assertEquals(Integer.parseInt(props.getProperty("displayWidth")), x2 - x1 + 1, + what + " displayWidth"); + assertEquals(Integer.parseInt(props.getProperty("displayHeight")), y2 - y1 + 1, + what + " displayHeight"); + } + + private BufferedImage read(ZipFile zip, String skinName, String name) throws Exception { + InputStream in = zip.getInputStream(entry(zip, skinName, name)); + try { + BufferedImage img = ImageIO.read(in); + assertNotNull(img, skinName + " entry " + name + " is not a readable image"); + return img; + } finally { + in.close(); + } + } + + private ZipEntry entry(ZipFile zip, String skinName, String name) { + ZipEntry e = zip.getEntry(name); + assertNotNull(e, skinName + " must contain " + name + + "; without it JavaSEPort.loadSkinFile dereferences a null map for every " + + "non-round skin"); + return e; + } + + /** + * The skins are generated into the module's output directory at process-resources, so they are + * on the test classpath. Resolved through the classloader rather than a hard-coded target/ + * path so the test does not depend on the build directory's name. + */ + private File locate(String skinName) { + java.net.URL url = getClass().getResource("/" + skinName); + assertNotNull(url, skinName + " was not generated into the simulator's resources"); + return new File(url.getPath()); + } +} diff --git a/tools/watch-skins/GenerateWatchSkins.java b/tools/watch-skins/GenerateWatchSkins.java index e1635762ae9..5ed1b8f6e75 100644 --- a/tools/watch-skins/GenerateWatchSkins.java +++ b/tools/watch-skins/GenerateWatchSkins.java @@ -38,8 +38,9 @@ * is true and the "watch" override layer applies -- with simple programmatically * drawn bezel artwork. Replace skin.png with final design art when available. * - * Each generated *.skin is a ZIP containing: skin.png, skin_l.png, - * skin.properties and a theme .res copied from the bundled iPhoneX.skin. + * Each generated *.skin is a ZIP containing: skin.png, skin_l.png, skin_map.png, + * skin_map_l.png, skin.properties and a theme .res copied from the bundled + * iPhoneX.skin. * * Regenerate the shipped skins with: * javac -d /tmp/wskin tools/watch-skins/GenerateWatchSkins.java @@ -159,6 +160,34 @@ static void generate(Model m, byte[] themeRes, File outDir) throws Exception { ImageIO.write(skin, "png", png); byte[] skinPng = png.toByteArray(); + // The coordinate map, and it is not optional artwork. + // + // JavaSEPort takes the screen rectangle and the bezel hotspot table out of a companion + // image the same size as the skin: white is bezel, black is the display, any other colour + // is a key mapped through a c property. A skin without one loaded fine right up to + // initializeCoordinates, which dereferences it -- so every NON-round watch skin (both Apple + // Watch sizes and Wear Square) died with a NullPointerException the moment it was selected. + // The round path never reads the map, which is why Wear Round alone appeared to work. + // + // Emitted for the round faces too. It costs a few hundred bytes, it keeps every archive the + // same shape, and it means the answer no longer depends on which branch of the loader a + // future edit happens to take. + BufferedImage mapImage = new BufferedImage(imgW, imgH, BufferedImage.TYPE_INT_RGB); + Graphics2D mg = mapImage.createGraphics(); + mg.setColor(Color.WHITE); + mg.fillRect(0, 0, imgW, imgH); + // A rectangle even for a circular face: the loader reads the black region's BOUNDING BOX, + // and an oval's bounding box is this rectangle anyway. Filling the rectangle keeps the two + // faces reporting identical geometry instead of leaving it to antialiasing at the rim. + mg.setColor(Color.BLACK); + mg.fillRect(displayX, displayY, m.dw, m.dh); + mg.dispose(); + // No hotspots: these skins have no mapped bezel keys. The crown and the side button are + // artwork -- rotary input arrives as a wheel event, not as a skin key. + ByteArrayOutputStream mapPng = new ByteArrayOutputStream(); + ImageIO.write(mapImage, "png", mapPng); + byte[] skinMapPng = mapPng.toByteArray(); + // Safe-area inset: the curve eats the corners, so content has to stay clear of them. A round // face loses far more than a rounded rectangle does -- inscribing a rectangle in a circle // costs about 15% a side -- and getting this wrong in the simulator is precisely the bug @@ -205,6 +234,8 @@ static void generate(Model m, byte[] themeRes, File outDir) throws Exception { ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(out)); putEntry(zos, "skin.png", skinPng); putEntry(zos, "skin_l.png", skinPng); + putEntry(zos, "skin_map.png", skinMapPng); + putEntry(zos, "skin_map_l.png", skinMapPng); putEntry(zos, "skin.properties", p.toString().getBytes("UTF-8")); putEntry(zos, m.themeEntry, themeRes); zos.close(); From f4f37f3ded23444cdba71f04df506a451fcc2fb9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:02:59 +0700 Subject: [PATCH 214/250] Wearables: vendored watch frameworks link, and sensor write-through counts as health Two more review findings on the same theme -- something the watch needs, skipped because the check only knew how to speak for the phone. A vendored .framework is referenced from a group rather than SDKROOT, and the SDK presence check has nothing to say about one, so every non-system framework was excluded outright. A third-party binary shipping a perfectly good watchOS slice therefore never reached the watch target, and the watch link failed with undefined symbols and nothing naming what was left out. The bundle's own Info.plist now decides -- CFBundleSupportedPlatforms, or AvailableLibraries for an .xcframework -- because guessing from architectures cannot work: arm64 is both an iPhone and an Apple silicon watch simulator. Linking alone was not enough either: FRAMEWORK_SEARCH_PATHS is mirrored per configuration so the linker can find the binary, and the embed phase is mirrored rather than decided, since the phone target already records whether this framework is dynamic. The per-root health walk excluded com/codename1/health/sensors entirely on the grounds that it is BLE-only. It is, until a session is told to write its samples through to the store -- which the app-wide scanner already treats as health write usage. The target doing the writing was judged not to reach health at all and lost both its entitlement and its listener bindings. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 33 +++++- .../builders/WatchNativeBuilder.java | 109 ++++++++++++++++-- .../IPhoneBuilderHealthListenerScopeTest.java | 32 +++++ .../builders/WatchNativeBuilderTest.java | 33 ++++++ 4 files changed, 194 insertions(+), 13 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 e823a16c8d7..8e59ae50640 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 @@ -314,7 +314,7 @@ private java.util.List classReferences(File classFile) { } /// Whether the class graph rooted here reaches the health API at all. - private boolean reachesHealth(java.util.Set reachable) { + boolean reachesHealth(java.util.Set reachable) { for (String name : reachable) { // The SAME distinction the scanner draws, and for the same reason. The umbrella // package is not evidence of HealthKit: com.codename1.health.sensors is pure BLE, and @@ -322,9 +322,26 @@ private boolean reachesHealth(java.util.Set reachable) { // that never touches the store. Counting those here marked a BLE-only phone root as // health-reaching and re-entitled it -- the exact release-signing failure this // attribution exists to prevent. - if (name.startsWith("com/codename1/health/") - && !name.startsWith("com/codename1/health/sensors/") - && !"com/codename1/health/Health".equals(name) + if (!name.startsWith("com/codename1/health/")) { + continue; + } + if (name.startsWith("com/codename1/health/sensors/")) { + // Normally BLE and nothing more -- but a sensor session can be told to write its + // samples through to the store, and then the sensors package IS the route into + // HealthKit. Excluding it unconditionally stripped the entitlement and the listener + // bindings from whichever target actually does the writing, which is the failure + // this attribution exists to prevent, arriving from the other direction. + // + // The write-through flag is app-wide, so a root that merely touches the sensors + // package is entitled alongside the one that writes. That is the safe direction: + // over-entitling costs nothing at runtime, under-entitling fails the authorization + // request. + if (sensorWriteThrough) { + return true; + } + continue; + } + if (!"com/codename1/health/Health".equals(name) && !isSharedHealthModel(name)) { return true; } @@ -480,6 +497,13 @@ private static boolean healthCapabilityRequested(BuildRequest request, String al private boolean usesHealthRead; private boolean usesHealthWrite; private boolean usesHealthWorkout; + + /// Whether a sensor session was seen asking to write its samples through to the store. + /// + /// Kept apart from usesHealthWrite, which write-through also sets: the two are the same + /// permission but not the same evidence, and only this one says the sensors package is a route + /// into HealthKit for the root that reaches it. + boolean sensorWriteThrough; private boolean usesCn1Camera; private boolean usesCn1Ar; private boolean usesCn1Vision; @@ -1533,6 +1557,7 @@ public void usesClassMethodWithBooleanArgument(String cls, usesHealth = true; usesHealthStore = true; usesHealthWrite = true; + sensorWriteThrough = true; } } 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 a22cec79c76..e3917f69e68 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 @@ -1700,7 +1700,46 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // // Weak-linked, as the optional-framework list treats anything a slice may not need: a // symbol the watch code never calls costs it nothing. - s.append("watch_sdks = ['watchos', 'watchsimulator'].map { |sdk| " + // A VENDORED framework is judged by what it says about itself. + // + // The SDK check below can only speak for system frameworks. A developer's own or a + // third-party binary is referenced from a group rather than SDKROOT, and skipping every one + // of those meant a watch-reachable native implementation calling into a framework that ships + // a perfectly good watchOS slice never got it linked -- undefined symbols, with nothing in + // the build naming what was left out. + // + // The bundle's own Info.plist is the honest answer: CFBundleSupportedPlatforms for a plain + // .framework, AvailableLibraries/SupportedPlatform for an .xcframework. Both are written by + // whoever built the binary. Guessing from the architecture list would not work -- arm64 is + // both an iPhone and an Apple silicon watch simulator -- and guessing wrong here links an + // iOS-only binary into a watch slice, which fails later and less clearly. + s.append("def cn1_watch_bundle_supports_watchos(ref)\n") + .append(" path = (ref.real_path.to_s rescue nil)\n") + .append(" return false unless path && File.exist?(path)\n") + .append(" plist = ['Info.plist', 'Resources/Info.plist']" + + ".map { |rel| File.join(path, rel) }.find { |c| File.file?(c) }\n") + .append(" return false unless plist\n") + .append(" begin\n") + .append(" info = Xcodeproj::Plist.read_from_path(plist)\n") + .append(" rescue StandardError\n") + // An unreadable plist is not evidence of support. Leaving it out costs a link + // error naming the framework; guessing yes costs a slice built against the wrong + // platform. + .append(" return false\n") + .append(" end\n") + .append(" return false unless info.is_a?(Hash)\n") + .append(" platforms = info['CFBundleSupportedPlatforms']\n") + .append(" if platforms.is_a?(Array) && platforms.any? { |p| " + + "p.to_s.downcase.start_with?('watch') }\n") + .append(" return true\n") + .append(" end\n") + .append(" libs = info['AvailableLibraries']\n") + .append(" return false unless libs.is_a?(Array)\n") + .append(" libs.any? { |l| l.is_a?(Hash) && " + + "l['SupportedPlatform'].to_s.downcase.start_with?('watch') }\n") + .append("end\n") + .append("vendored_linked = false\n") + .append("watch_sdks = ['watchos', 'watchsimulator'].map { |sdk| " + "`xcrun --sdk #{sdk} --show-sdk-path 2>/dev/null`.strip }" + ".reject { |dir| dir.empty? || !File.directory?(dir) }\n") .append("watch_fw_dirs = watch_sdks.map { |dir| " @@ -1720,15 +1759,19 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // .a is deliberately absent. A static library is built, not provided by the SDK: // CocoaPods' libPods-*.a and any vendored archive are compiled for iOS and have no // watch slice, so linking one is a guaranteed failure rather than a possible one. - .append(" next unless base.end_with?('.framework') || base.end_with?('.dylib') " + .append(" next unless base.end_with?('.framework') " + + "|| base.end_with?('.xcframework') || base.end_with?('.dylib') " + "|| base.end_with?('.tbd')\n") - .append(" if base.end_with?('.framework')\n") - // Only a system framework can be checked against the SDK. An embedded or vendored - // .framework is the developer's own binary and nothing here can tell whether it has - // a watch slice, so it is left alone rather than guessed at. - .append(" next unless ref.source_tree == 'SDKROOT'\n") - .append(" present = !watch_fw_dirs.empty? && watch_fw_dirs.all? { |dirs| " + .append(" if base.end_with?('.framework') || base.end_with?('.xcframework')\n") + .append(" if ref.source_tree == 'SDKROOT'\n") + .append(" present = !watch_fw_dirs.empty? && watch_fw_dirs.all? { |dirs| " + "dirs.any? { |d| File.directory?(File.join(d, base)) } }\n") + .append(" else\n") + // Vendored: the bundle's own declaration decides, and a yes means the search paths + // and any embed phase have to follow it below. + .append(" present = cn1_watch_bundle_supports_watchos(ref)\n") + .append(" vendored_linked ||= present\n") + .append(" end\n") .append(" else\n") .append(" stem = base.sub(/\\.(dylib|tbd)\\z/, '')\n") .append(" present = !watch_sdks.empty? && watch_sdks.all? { |sdk| " @@ -1737,7 +1780,8 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, .append(" end\n") .append(" unless present\n") .append(" puts \"[watchNative] not linking #{base} into the watch target: " - + "absent from a watchOS SDK\"\n") + + "#{ref.source_tree == 'SDKROOT' ? 'absent from a watchOS SDK' : " + + "'its Info.plist declares no watchOS slice'}\"\n") .append(" next\n") .append(" end\n") .append(" added = watch_target.frameworks_build_phase.add_file_reference(ref)\n") @@ -1750,6 +1794,53 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, .append(" end\n") .append("end\n"); + // What a vendored framework needs beyond being listed in the link phase. + // + // FRAMEWORK_SEARCH_PATHS is where the linker looks, and it lives on the APP target -- the + // watch target has never been told about the directory the binary sits in, so linking the + // reference alone still fails with "framework not found". Mirrored per configuration, so a + // project whose Debug and Release paths differ keeps that difference. + // + // Then the embed phase, mirrored rather than decided: a static framework must NOT be + // copied into the bundle and a dynamic one must, and whether this particular binary is one + // or the other is already recorded in the project -- if the phone app embeds it, it is + // dynamic. Reading that beats parsing Mach-O headers to rediscover it. + // + // Both are gated on having actually linked a vendored framework, so a project without one + // produces exactly the Xcode project it did before. + s.append("if vendored_linked\n") + .append(" watch_target.build_configurations.each do |config|\n") + .append(" app_cfg = app_target.build_configurations.find { |c| " + + "c.name == config.name } || app_target.build_configurations.first\n") + .append(" next unless app_cfg\n") + .append(" paths = app_cfg.build_settings['FRAMEWORK_SEARCH_PATHS']\n") + .append(" next unless paths\n") + .append(" config.build_settings['FRAMEWORK_SEARCH_PATHS'] = paths\n") + .append(" end\n") + .append(" embedded = app_target.copy_files_build_phases.to_a.select { |ph| " + + "ph.symbol_dst_subfolder_spec == :frameworks }" + + ".flat_map { |ph| ph.files.to_a }" + + ".map { |bf| bf.file_ref }.compact\n") + .append(" watch_embed = nil\n") + .append(" watch_linked = watch_target.frameworks_build_phase.files_references\n") + .append(" embedded.each do |ref|\n") + .append(" next unless watch_linked.include?(ref)\n") + .append(" watch_embed ||= watch_target.copy_files_build_phases.to_a.find { |ph| " + + "ph.symbol_dst_subfolder_spec == :frameworks }\n") + .append(" if watch_embed.nil?\n") + .append(" watch_embed = watch_target.new_copy_files_build_phase(" + + "'Embed Frameworks')\n") + .append(" watch_embed.symbol_dst_subfolder_spec = :frameworks\n") + .append(" end\n") + .append(" next if watch_embed.files_references.include?(ref)\n") + .append(" bf = watch_embed.add_file_reference(ref)\n") + // Signed on copy, as Xcode does for an embedded framework: an unsigned binary + // inside a signed watch app is rejected at install time. + .append(" bf.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', " + + "'RemoveHeadersOnCopy'] } if bf\n") + .append(" end\n") + .append("end\n"); + // Swift Package Manager products, which the loop above cannot see. // // A build file for a package product carries a `product_ref` and NO `file_ref`, so every diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java index 9d300453b72..f1f0e6128c6 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java @@ -107,6 +107,38 @@ void oneTranslationFiltersNothing() { assertSame(all, builder.healthListenersReachableFrom(all, null)); } + /** + * The sensors package is BLE-only right up until a session is told to write through. + * + *

{@code SensorSessionOptions.setWriteToStore(true)} saves samples into HealthKit, and the + * app-wide scanner already treats that as health write usage. The per-root walk excluded + * everything under {@code health/sensors/} unconditionally, so the target actually doing the + * writing was judged not to reach health at all and lost both its entitlement and its listener + * bindings -- the same failure this attribution exists to prevent, from the other side.

+ */ + @Test + void sensorWriteThroughMakesTheSensorsPackageReachHealth() { + IPhoneBuilder builder = new IPhoneBuilder(); + Set sensorsOnly = reachable("com/acme/MyApp", + "com/codename1/health/sensors/SensorSession", + "com/codename1/health/sensors/SensorSessionOptions"); + + assertFalse(builder.reachesHealth(sensorsOnly), + "a BLE-only root must not be entitled for HealthKit"); + + builder.sensorWriteThrough = true; + assertTrue(builder.reachesHealth(sensorsOnly), + "but writing samples through to the store is HealthKit use"); + } + + /** Write-through elsewhere in the app does not make an unrelated root reach health. */ + @Test + void writeThroughDoesNotEntitleARootThatTouchesNoSensor() { + IPhoneBuilder builder = new IPhoneBuilder(); + builder.sensorWriteThrough = true; + assertFalse(builder.reachesHealth(reachable("com/acme/WatchApp", "com/acme/Ui"))); + } + /** A root that reaches no listener binds none, rather than falling back to all of them. */ @Test void aRootThatReachesNoListenerBindsNone() { 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 c31550a1d2d..65792225623 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 @@ -704,6 +704,39 @@ void swiftPackageProductsReachTheWatchTarget(@TempDir Path tmp) throws Exception assertTrue(ruby.contains("next if watch_target.package_product_dependencies.any?"), ruby); } + /** + * A vendored framework declaring a watchOS slice reaches the watch target. + * + *

Only a system framework can be checked against the SDK, so everything referenced from a + * group was skipped outright -- including a third-party binary that ships a perfectly good + * watchOS slice, whose absence surfaced as undefined symbols with nothing naming what was left + * out. The bundle's own Info.plist is what decides now.

+ */ + @Test + void vendoredFrameworksDeclaringWatchosAreLinkedEmbeddedAndFound(@TempDir Path tmp) + throws Exception { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + String ruby = parse(req).buildXcodeScript(req, tmp.toFile(), "1.0", + java.util.Collections.emptyList()); + + // Both spellings of a declaration, because an .xcframework records it differently. + assertTrue(ruby.contains("CFBundleSupportedPlatforms"), ruby); + assertTrue(ruby.contains("AvailableLibraries"), ruby); + assertTrue(ruby.contains("cn1_watch_bundle_supports_watchos(ref)"), + "a non-SDKROOT reference must be judged by its own bundle: " + ruby); + // Linking alone is not enough. The linker has to be told where the binary lives, and a + // dynamic framework has to be copied into the watch bundle or it fails at install time. + assertTrue(ruby.contains("config.build_settings['FRAMEWORK_SEARCH_PATHS'] = paths"), ruby); + assertTrue(ruby.contains("symbol_dst_subfolder_spec == :frameworks"), ruby); + assertTrue(ruby.contains("'CodeSignOnCopy'"), ruby); + // Whether to embed is READ from the phone target rather than decided here: if the app + // embeds it, it is dynamic. A static framework must not be copied. + assertTrue(ruby.contains("app_target.copy_files_build_phases"), ruby); + // And a project with no vendored framework must produce the project it did before. + assertTrue(ruby.contains("if vendored_linked"), ruby); + } + /// The bootstrap has to call the stub that actually exists in the watch binary. @Test void theBootstrapEntersTheWatchStub(@TempDir Path tmp) throws Exception { From c04752d0f1c03827b9903b3d3e5add8787e6ce25 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:08:27 +0700 Subject: [PATCH 215/250] Wearables: an EDT read of replicated data is authoritative, not a cold cache getData() and getDataPaths() answered an EDT caller from a cache rather than blocking it, which is the right instinct and the wrong contract. Both document absence -- null, and an empty list -- and after a cold launch the cache is empty for durable state that still exists, because the Data Layer has no reason to re-announce an item it delivered before the restart. A UI action reading either one concluded there was no state and discarded it, and priming in the background only makes the NEXT call right when there may not be a next call. invokeAndBlock is what this needed all along: the query runs off the EDT while the EDT keeps painting and handling input, so the caller gets the real answer without a five-second freeze. Android's main thread keeps the cache-and-prime path -- it has no invokeAndBlock and blocking it is an ANR -- and only internal Play services callbacks arrive there. Co-Authored-By: Claude Opus 5 (1M context) --- .../wearable/WearableConnection.java | 9 +++ .../builders/wearable/CN1WearableBridge.java | 61 ++++++++++++++++--- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/CodenameOne/src/com/codename1/wearable/WearableConnection.java b/CodenameOne/src/com/codename1/wearable/WearableConnection.java index afdf50fb6bc..0268b16a775 100644 --- a/CodenameOne/src/com/codename1/wearable/WearableConnection.java +++ b/CodenameOne/src/com/codename1/wearable/WearableConnection.java @@ -292,6 +292,12 @@ public static void putData(WearableMessage data) { /// Reads the replicated value at a path, as published by either side. /// + /// Null means the path holds nothing, and it is safe to act on that: where the platform has to + /// ask its own replication layer -- Android does -- the query is authoritative rather than + /// answered from a cache that a cold launch leaves empty. Called on the EDT that query runs + /// through `invokeAndBlock`, so the UI keeps painting while it waits; as with any + /// `invokeAndBlock`, do not call this from `paint()`. + /// /// #### Parameters /// /// - `path`: the path to read @@ -323,6 +329,9 @@ public static void removeData(String path) { /// Returns every path that currently holds a replicated value. /// + /// An empty list means there is nothing published, not "not enumerated yet" -- see + /// [#getData(String)] for how that is arranged and what it costs on the EDT. + /// /// #### Returns /// /// the published paths, never null 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 8598d9ae1ff..893d6eaa31a 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 @@ -1340,12 +1340,32 @@ static synchronized void observeSequence(long seen) { private static long lastSequence; public byte[] getData(String path) { - // On the EDT (or Android's main thread) answer from the last known snapshot instead of - // blocking. resolveValue() waits on Play services for up to TIMEOUT_SECONDS, and taking - // that on the EDT freezes painting and input for the duration -- the state queries in this - // class already refuse to do it, and this public getter had no such guard. A caller that - // needs an authoritative read can make one off the EDT; a caller that is painting cannot - // afford five seconds either way. + // The EDT gets the AUTHORITATIVE answer, obtained without freezing. + // + // Answering an EDT caller from the cache was wrong in a way the contract cannot absorb: + // this getter documents null as "nothing is published here", and after a cold launch the + // cache is empty for durable state that still exists -- the Data Layer has no reason to + // re-announce an item it delivered before the restart. A UI action reading it then + // concluded there was no state and discarded valid data. Priming in the background does not + // fix that; it only makes the NEXT call right, and there may not be a next call. + // + // invokeAndBlock is exactly the tool for this: the query runs off the EDT while the EDT + // keeps pumping paint and input, so the caller gets the real answer and the UI does not + // freeze for TIMEOUT_SECONDS. As with any invokeAndBlock, do not call this from paint(). + if (com.codename1.ui.CN.isEdt()) { + final String requested = path; + final byte[][] resolved = new byte[1][]; + com.codename1.ui.Display.getInstance().invokeAndBlock(new Runnable() { + public void run() { + resolved[0] = resolveDataBlocking(requested); + } + }); + return resolved[0]; + } + // Android's main thread is not the EDT and has no invokeAndBlock: this is where a Play + // services completion listener runs, and blocking it is an ANR rather than a slow frame. + // Internal callers only -- app code reaching this getter is on the EDT or a thread of its + // own -- so the cached answer plus a background prime is the best available here. if (isCallerLatencySensitive()) { byte[] cached = cachedValue(path); boolean absent; @@ -1362,6 +1382,11 @@ public byte[] getData(String path) { } return cached; } + return resolveDataBlocking(path); + } + + /// The blocking resolution, on a thread that can afford it. + private byte[] resolveDataBlocking(String path) { // Deliberately the same resolution the listener uses. This used to have its own loop, which // kept whichever item the buffer yielded first -- so once resolveValue() gained the // publisher tie-break, getData() could return a different value than the listener had just @@ -1674,9 +1699,22 @@ static boolean isLocallyRemoved(String storagePath) { } public String[] getDataPaths() { - // Same reasoning as getData: this await can stall a painting thread for TIMEOUT_SECONDS. - // An EDT caller gets the last successful enumeration, or an empty array if there has not - // been one yet, rather than a frozen UI. + // Same reasoning as getData, and the same answer. An empty array read as "there is no + // persisted wearable state" is how one-shot initialisation code decides never to read it, + // and after a cold launch that is exactly what an unenumerated cache produced. The EDT gets + // the real enumeration through invokeAndBlock, which keeps painting and input alive while + // the await runs. + if (com.codename1.ui.CN.isEdt()) { + final String[][] enumerated = new String[1][]; + com.codename1.ui.Display.getInstance().invokeAndBlock(new Runnable() { + public void run() { + enumerated[0] = enumerateDataPathsBlocking(); + } + }); + return enumerated[0]; + } + // Android's main thread: no invokeAndBlock, and blocking it is an ANR. Internal callers + // only; app code is on the EDT or a thread of its own. if (isCallerLatencySensitive()) { String[] known = pathsCache; if (known == null) { @@ -1688,6 +1726,11 @@ public String[] getDataPaths() { } return known.clone(); } + return enumerateDataPathsBlocking(); + } + + /// The blocking enumeration, on a thread that can afford it. + private String[] enumerateDataPathsBlocking() { int startedGeneration; synchronized (valueCache) { startedGeneration = pathsGeneration; From 2e4261c3b814649443457d15725fe432b92b6724 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:12:54 +0700 Subject: [PATCH 216/250] Wearables: the watch stub registers only the natives the watch reaches NativeLookup.register(X.class, XStub.class) is a hard reference and XStub holds one to the generated implementation, so a stub installing the app-wide list roots every native implementation in its own translation. With a distinct watchMain that meant a phone-only native interface had its Objective-C staged and compiled for watchOS, where a UIKit import or an iOS-only symbol fails the build in code the watch never calls. The watch stub's registrations are now filtered by the watch root's reachable set, like its health bindings. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 52 +++++++++++++++++-- .../IPhoneBuilderHealthListenerScopeTest.java | 33 ++++++++++++ 2 files changed, 82 insertions(+), 3 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 8e59ae50640..7180817f358 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 @@ -430,6 +430,50 @@ private String healthBindingsInstall(java.util.Map listeners, return statement == null ? "" : " " + statement; } + /// The native-interface registrations one translation root can reach. + /// + /// Every `NativeLookup.register(X.class, XStub.class)` is a hard reference, and XStub holds one + /// to the generated implementation -- so a stub installing the app-wide list roots every native + /// implementation in its own translation. A phone-only native interface then had its + /// Objective-C staged and compiled for watchOS, where a UIKit import or an iOS-only symbol is a + /// build failure in code the watch never calls. + /// + /// A watch root that uses a native interface names it: NativeLookup.create takes a class + /// literal, which lands in the caller's constant pool. As everywhere else here the walk + /// over-approximates, so anything the translation would keep is matched first. + String nativeRegistrationsReachableFrom(String registrations, + java.util.Set reachable) { + if (reachable == null || registrations == null || registrations.length() == 0) { + return registrations; + } + StringBuilder out = new StringBuilder(); + for (String line : registrations.split("\n")) { + String registered = registeredInterfaceName(line); + // A line that registers nothing is passed through untouched rather than guessed at: + // this block is generated, but it is not this method's job to decide what else may + // legitimately appear in it. + if (registered == null || reachable.contains(registered.replace('.', '/'))) { + out.append(line).append('\n'); + } + } + return out.toString(); + } + + /// The interface named by one `NativeLookup.register(...)` line, or null if it is not one. + private String registeredInterfaceName(String line) { + final String open = "NativeLookup.register("; + int start = line.indexOf(open); + if (start < 0) { + return null; + } + start += open.length(); + int end = line.indexOf(".class", start); + if (end <= start) { + return null; + } + return line.substring(start, end).trim(); + } + java.util.Map healthListenersReachableFrom( java.util.Map all, java.util.Set reachable) { if (reachable == null || all == null || all.isEmpty()) { @@ -2502,9 +2546,11 @@ public void usesClassMethod(String cls, String method) { // point actually reaches. if (watchNativeBuilder.needsOwnTranslation()) { watchNativeBuilder.writeWatchStubSource(request, stubSource, buildVersion, - registerNativeImplementationsAndCreateStubs( - new URLClassLoader(new URL[]{codenameOneJar.toURI().toURL()}), - stubSource, classesDir), + nativeRegistrationsReachableFrom( + registerNativeImplementationsAndCreateStubs( + new URLClassLoader(new URL[]{codenameOneJar.toURI().toURL()}), + stubSource, classesDir), + watchReachableClasses), iosMode, svgRegistryInstall, watchHealthBindingsInstall, routeDispatcherInstallSource(sourceZip, " "), annotationFrameworksInstallSource(sourceZip, " ")); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java index f1f0e6128c6..9e038e4a0e2 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java @@ -139,6 +139,39 @@ void writeThroughDoesNotEntitleARootThatTouchesNoSensor() { assertFalse(builder.reachesHealth(reachable("com/acme/WatchApp", "com/acme/Ui"))); } + /** + * A phone-only native interface is not registered in the watch stub. + * + *

Each registration is a hard reference, and the generated stub it names holds one to the + * native implementation -- so the app-wide list rooted every native implementation in the watch + * translation, and a phone-only one had its Objective-C compiled for watchOS, where a UIKit + * import is a build failure in code the watch never calls.

+ */ + @Test + void theWatchStubRegistersOnlyTheNativesItReaches() { + IPhoneBuilder builder = new IPhoneBuilder(); + String all = " NativeLookup.register(com.acme.PhoneNative.class," + + " com.acme.PhoneNativeStub.class);\n" + + " NativeLookup.register(com.acme.WatchNative.class," + + " com.acme.WatchNativeStub.class);\n"; + + String watch = builder.nativeRegistrationsReachableFrom(all, + reachable("com/acme/WatchApp", "com/acme/WatchNative")); + assertTrue(watch.contains("com.acme.WatchNative.class"), watch); + assertFalse(watch.contains("com.acme.PhoneNative.class"), + "the watch must not root the phone's native implementation: " + watch); + } + + /** One translation registers everything, exactly as before. */ + @Test + void oneTranslationRegistersEveryNative() { + IPhoneBuilder builder = new IPhoneBuilder(); + String all = " NativeLookup.register(com.acme.PhoneNative.class," + + " com.acme.PhoneNativeStub.class);\n"; + assertEquals(all, builder.nativeRegistrationsReachableFrom(all, null)); + assertEquals("", builder.nativeRegistrationsReachableFrom("", reachable("com/acme/X"))); + } + /** A root that reaches no listener binds none, rather than falling back to all of them. */ @Test void aRootThatReachesNoListenerBindsNone() { From 42fc55044937d616fa41f1c65fb31c93e1535263 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:03:51 +0700 Subject: [PATCH 217/250] Wearables: the watch slice does not import GoogleSignIn, and keeps every native registration Two watch build failures, one of them mine. Revert of "the watch stub registers only the natives the watch reaches". The safety argument for that filter had a hole: the reachability walk only follows references it can resolve INSIDE classesDir, and stops at the first class that lives in the core jar or a cn1lib -- so app-lifecycle -> core class -> app native interface reads as unreachable and the registration was dropped. That is the under-approximating direction I claimed it could not take, and the watch screenshot suite hung at its first capture with the app running and streaming nothing. Filtering these needs a walk over the whole classpath, not over classesDir, which is not a change to make under a red build. The GoogleSignIn break is older and is a real one. The builder switches GOOGLE_SIGNIN and INCLUDE_GOOGLE_CONNECT on by EDITING CodenameOne_GLViewController.h, so both arrive on every slice at once -- and the watch translation stages that same header. Neither Google SDK ships a watchOS slice, so the device build failed with "'GoogleSignIn/GoogleSignIn.h' file not found" from a native source the watch never calls into. Undefining both under TARGET_OS_WATCH also empties the implementation blocks in the .m files, since every one of them is gated on the same two macros -- which is exactly the state an app without Google login already builds in. Co-Authored-By: Claude Opus 5 (1M context) --- .../CodenameOne_GLViewController.h | 10 ++++ .../com/codename1/builders/IPhoneBuilder.java | 52 ++----------------- .../IPhoneBuilderHealthListenerScopeTest.java | 33 ------------ 3 files changed, 13 insertions(+), 82 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index 950dcccd177..8d07014cc7b 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -58,6 +58,16 @@ void cn1RunSyncOnMainQueue(void (^block)(void)); //#define GOOGLE_SIGNIN //#define GOOGLE_CONNECT_PODS //#define INCLUDE_GOOGLE_CONNECT +#if TARGET_OS_WATCH +// Neither Google SDK ships a watchOS slice, and the watch app cannot present a sign-in web flow +// anyway. Both defines are switched on by the builder EDITING THIS FILE, so they arrive on every +// slice of the project at once -- the watch translation stages the same header and then failed to +// build with "'GoogleSignIn/GoogleSignIn.h' file not found", from a native source the watch never +// calls into. Undefining here rather than guarding each import turns off the implementation blocks +// in the .m files too, since every one of them is gated on the same two macros. +#undef GOOGLE_SIGNIN +#undef INCLUDE_GOOGLE_CONNECT +#endif #ifndef GOOGLE_SIGNIN #ifdef INCLUDE_GOOGLE_CONNECT #ifdef GOOGLE_CONNECT_PODS 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 7180817f358..8e59ae50640 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 @@ -430,50 +430,6 @@ private String healthBindingsInstall(java.util.Map listeners, return statement == null ? "" : " " + statement; } - /// The native-interface registrations one translation root can reach. - /// - /// Every `NativeLookup.register(X.class, XStub.class)` is a hard reference, and XStub holds one - /// to the generated implementation -- so a stub installing the app-wide list roots every native - /// implementation in its own translation. A phone-only native interface then had its - /// Objective-C staged and compiled for watchOS, where a UIKit import or an iOS-only symbol is a - /// build failure in code the watch never calls. - /// - /// A watch root that uses a native interface names it: NativeLookup.create takes a class - /// literal, which lands in the caller's constant pool. As everywhere else here the walk - /// over-approximates, so anything the translation would keep is matched first. - String nativeRegistrationsReachableFrom(String registrations, - java.util.Set reachable) { - if (reachable == null || registrations == null || registrations.length() == 0) { - return registrations; - } - StringBuilder out = new StringBuilder(); - for (String line : registrations.split("\n")) { - String registered = registeredInterfaceName(line); - // A line that registers nothing is passed through untouched rather than guessed at: - // this block is generated, but it is not this method's job to decide what else may - // legitimately appear in it. - if (registered == null || reachable.contains(registered.replace('.', '/'))) { - out.append(line).append('\n'); - } - } - return out.toString(); - } - - /// The interface named by one `NativeLookup.register(...)` line, or null if it is not one. - private String registeredInterfaceName(String line) { - final String open = "NativeLookup.register("; - int start = line.indexOf(open); - if (start < 0) { - return null; - } - start += open.length(); - int end = line.indexOf(".class", start); - if (end <= start) { - return null; - } - return line.substring(start, end).trim(); - } - java.util.Map healthListenersReachableFrom( java.util.Map all, java.util.Set reachable) { if (reachable == null || all == null || all.isEmpty()) { @@ -2546,11 +2502,9 @@ public void usesClassMethod(String cls, String method) { // point actually reaches. if (watchNativeBuilder.needsOwnTranslation()) { watchNativeBuilder.writeWatchStubSource(request, stubSource, buildVersion, - nativeRegistrationsReachableFrom( - registerNativeImplementationsAndCreateStubs( - new URLClassLoader(new URL[]{codenameOneJar.toURI().toURL()}), - stubSource, classesDir), - watchReachableClasses), + registerNativeImplementationsAndCreateStubs( + new URLClassLoader(new URL[]{codenameOneJar.toURI().toURL()}), + stubSource, classesDir), iosMode, svgRegistryInstall, watchHealthBindingsInstall, routeDispatcherInstallSource(sourceZip, " "), annotationFrameworksInstallSource(sourceZip, " ")); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java index 9e038e4a0e2..f1f0e6128c6 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java @@ -139,39 +139,6 @@ void writeThroughDoesNotEntitleARootThatTouchesNoSensor() { assertFalse(builder.reachesHealth(reachable("com/acme/WatchApp", "com/acme/Ui"))); } - /** - * A phone-only native interface is not registered in the watch stub. - * - *

Each registration is a hard reference, and the generated stub it names holds one to the - * native implementation -- so the app-wide list rooted every native implementation in the watch - * translation, and a phone-only one had its Objective-C compiled for watchOS, where a UIKit - * import is a build failure in code the watch never calls.

- */ - @Test - void theWatchStubRegistersOnlyTheNativesItReaches() { - IPhoneBuilder builder = new IPhoneBuilder(); - String all = " NativeLookup.register(com.acme.PhoneNative.class," - + " com.acme.PhoneNativeStub.class);\n" - + " NativeLookup.register(com.acme.WatchNative.class," - + " com.acme.WatchNativeStub.class);\n"; - - String watch = builder.nativeRegistrationsReachableFrom(all, - reachable("com/acme/WatchApp", "com/acme/WatchNative")); - assertTrue(watch.contains("com.acme.WatchNative.class"), watch); - assertFalse(watch.contains("com.acme.PhoneNative.class"), - "the watch must not root the phone's native implementation: " + watch); - } - - /** One translation registers everything, exactly as before. */ - @Test - void oneTranslationRegistersEveryNative() { - IPhoneBuilder builder = new IPhoneBuilder(); - String all = " NativeLookup.register(com.acme.PhoneNative.class," - + " com.acme.PhoneNativeStub.class);\n"; - assertEquals(all, builder.nativeRegistrationsReachableFrom(all, null)); - assertEquals("", builder.nativeRegistrationsReachableFrom("", reachable("com/acme/X"))); - } - /** A root that reaches no listener binds none, rather than falling back to all of them. */ @Test void aRootThatReachesNoListenerBindsNone() { From b61f329ad38acdf7f1a3a2c723ccb772708b1659 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:40:50 +0700 Subject: [PATCH 218/250] Wearables: spool the deliveries nothing can reconstruct, and widen the health walk Four review findings plus the native-ios timeout. A one-shot message and a data removal were the two deliveries that could be lost outright. Both were handed to WearableConnection's in-memory queue from a service process, on the assumption that the background startActivity() above would bring a lifecycle up to drain it -- and Android 10+ refuses that start. The Data Layer retains neither: a message is not an item at all, and a removal's item is by definition gone, so startup replay reports nothing. Both now go through a durable spool that is written with commit() before anything else, drained oldest first when the app comes up, and cleared before replay so a throwing listener cannot turn a one-shot into a permanent one. A replicated value and a reply-bearing request keep the in-memory path: the first is still published and replayed, and an answer produced after the peer timed out is not an answer. A transfer acknowledgement no longer outlives a failed claim write. commit() returning false was ignored, so a full or unwritable store let the sender retire an item this device has no durable claim on -- and a restart then lost the transfer. The acknowledgement is withheld instead, which costs the sender an item until the hard cap and buys a redelivery this device will accept. The per-root health walk now starts at the generated registries as well as the lifecycle class. Both stubs install the route dispatcher and the annotation bootstraps, and each names every target it can dispatch to, so ParparVM retains a HealthKit screen reached only by route string while a lifecycle-rooted walk cannot see it -- that target shipped unentitled and was refused at runtime. The health binding factory is deliberately not a root: it names every listener, which is the circularity this attribution exists to break. The guide's summary table no longer advertises complications as a feature: the row says where those surfaces will come from, and the targets are not generated. native-ios timed out because the sample declares a watchMain now, so xcodebuild builds an embedded watch app with a ParparVM translation of its own before it can run a test -- 16 of its 25 minutes went on that. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/scripts-ios-native.yml | 6 +- docs/developer-guide/Wearables.asciidoc | 11 +- .../com/codename1/builders/IPhoneBuilder.java | 72 ++++- .../builders/wearable/CN1WearableBridge.java | 264 +++++++++++++++++- .../wearable/CN1WearableListenerService.java | 8 +- .../IPhoneBuilderHealthListenerScopeTest.java | 39 +++ 6 files changed, 379 insertions(+), 21 deletions(-) diff --git a/.github/workflows/scripts-ios-native.yml b/.github/workflows/scripts-ios-native.yml index cb4809e6267..788b585a71e 100644 --- a/.github/workflows/scripts-ios-native.yml +++ b/.github/workflows/scripts-ios-native.yml @@ -179,7 +179,11 @@ jobs: ./scripts/run-ios-native-tests.sh \ "${{ steps.build_ios_app.outputs.workspace }}" \ "${{ steps.build_ios_app.outputs.scheme }}" - timeout-minutes: 25 + # 25 was enough while the sample built one app. It declares a watchMain now, so the app + # scheme carries an embedded watch app with a ParparVM translation of its own, and + # xcodebuild builds that before it can run a single test -- 16 of the 25 minutes went on + # compiling it. The packaging job already allows 65 for the same reason. + timeout-minutes: 45 - name: Upload native iOS artifacts if: always() diff --git a/docs/developer-guide/Wearables.asciidoc b/docs/developer-guide/Wearables.asciidoc index a0f761a3155..ff7813b0ec7 100644 --- a/docs/developer-guide/Wearables.asciidoc +++ b/docs/developer-guide/Wearables.asciidoc @@ -441,10 +441,15 @@ Layer APIs directly. |`com.codename1.wearable` over WatchConnectivity |`com.codename1.wearable` over the Wearable Data Layer -|Complications -|WidgetKit accessory families -|Complication data source and Tiles +|Complications (no target generated yet) +|WidgetKit accessory families, declarable only +|Complication data source and Tiles, declarable only |=== +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. + The wearable build is additive on both platforms: without a watch main class, your phone builds are unchanged. 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 8e59ae50640..ee9626e0e49 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 @@ -192,13 +192,32 @@ private static String trimToNull(String v) { /// Roots are searched in order, which is how the isolated stubs are found: after the two entry /// points are separated each stub lives in a directory of its own and no longer in classesDir. java.util.Set reachableClasses(java.util.List roots, String rootInternal) { + return reachableClasses(roots, + java.util.Collections.singletonList(rootInternal)); + } + + /// The same, seeded from several roots at once. + /// + /// A translation has more entry points than its lifecycle class: each generated stub installs + /// the route dispatcher and the annotation bootstraps, and those registries name every target + /// they can dispatch to. A screen reached only by route string is therefore retained by the + /// translation while being invisible to a walk that starts at the lifecycle -- so a HealthKit + /// screen behind a route lost its target's entitlement and was refused at runtime. + java.util.Set reachableClasses(java.util.List roots, + java.util.Collection rootInternals) { java.util.Set seen = new java.util.HashSet(); - if (rootInternal == null || rootInternal.length() == 0) { + if (rootInternals == null) { return seen; } java.util.LinkedList pending = new java.util.LinkedList(); - seen.add(rootInternal); - pending.add(rootInternal); + for (String rootInternal : rootInternals) { + if (rootInternal == null || rootInternal.length() == 0) { + continue; + } + if (seen.add(rootInternal)) { + pending.add(rootInternal); + } + } while (!pending.isEmpty()) { String name = pending.removeFirst(); File classFile = null; @@ -379,15 +398,56 @@ void resolveHealthUsagePerRoot(BuildRequest request, File classesDir) { // very lifecycle that uses HealthKit. String phoneRoot = origMainClass != null && origMainClass.length() > 0 ? origMainClass : request.getMainClass(); - phoneReachableClasses = reachableClasses(roots, prefix + phoneRoot); - watchReachableClasses = reachableClasses(roots, - watchNativeBuilder.getWatchMain().replace('.', '/')); + // The registries BOTH stubs install are roots too. Each one names every class it can + // dispatch to, so the translation retains them -- a screen reached only by route string, or + // a mapper reached only through the bootstrap, is live code that a lifecycle-rooted walk + // cannot see. Reading it as unreachable stripped the entitlement from the target that + // retains the screen, and the authorization request was then refused at runtime. + // + // The health binding factory is deliberately NOT among them: it names every listener, so + // rooting the walk there would make both targets reach health whatever their lifecycle + // does, which is the circularity this attribution was built to break. It does not exist + // yet at this point either -- the answer here is what decides its contents. + java.util.List registryRoots = installedRegistryRoots(classesDir); + java.util.List phoneRoots = new java.util.ArrayList(registryRoots); + phoneRoots.add(prefix + phoneRoot); + java.util.List watchRoots = new java.util.ArrayList(registryRoots); + watchRoots.add(watchNativeBuilder.getWatchMain().replace('.', '/')); + phoneReachableClasses = reachableClasses(roots, phoneRoots); + watchReachableClasses = reachableClasses(roots, watchRoots); phoneRootReachesHealth = reachesHealth(phoneReachableClasses); watchRootReachesHealth = reachesHealth(watchReachableClasses); log("[watchNative] HealthKit reachability: phone=" + phoneRootReachesHealth + ", watch=" + watchRootReachesHealth); } + /// The generated registries a stub installs, as internal names, limited to the ones this + /// project actually produced. + /// + /// Kept in step with routeDispatcherInstallSource and annotationFrameworksInstallSource: those + /// decide what the stub instantiates, and this decides what the walk starts from. A registry + /// the project does not have is simply absent from classesDir and contributes nothing. + java.util.List installedRegistryRoots(File classesDir) { + String[] candidates = { + "com/codename1/router/generated/Routes", + "com/codename1/generated/svg/SVGRegistry", + "cn1app/MapperBootstrap", + "cn1app/BinderBootstrap", + "cn1app/DaoBootstrap", + "cn1app/RestClientBootstrap", + "cn1app/ProtoBootstrap", + "cn1app/GrpcClientBootstrap", + "cn1app/GraphQLClientBootstrap", + }; + java.util.List out = new java.util.ArrayList(); + for (String name : candidates) { + if (new File(classesDir, name.replace('/', File.separatorChar) + ".class").isFile()) { + out.add(name); + } + } + return out; + } + /// The listener bindings one translation root can actually reach. /// /// The scan that produced `all` walked the whole classes directory, so it answers "this app 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 893d6eaa31a..4a74c8df50e 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 @@ -151,6 +151,10 @@ public CN1WearableBridge(Context context) { this.capabilityClient = Wearable.getCapabilityClient(this.context); current = this; restoreClock(this.context); + // Anything a service process wrote down while the app was away, replayed before any live + // event of this run -- a one-shot message and a data removal have no other way back. First, + // because these are older than whatever arrives next and the app should see them in order. + drainSpool(this.context); // Sweep at startup as well as after each publish. An app that sends a few files and then // stops would otherwise never run the sweep again, leaving its last transfers published // indefinitely -- the post-publish sweep only helps an app that keeps transferring. @@ -1286,11 +1290,223 @@ static void ensureAppRunning() { c.startActivity(launch); } } catch (Throwable notPermitted) { - // Background activity starts are restricted on newer Android. The delivery stays in the - // in-memory queue and is replayed if the app opens while this process is still alive. + // Background activity starts are restricted on newer Android. Nothing is lost by that + // any more: the two deliveries that cannot be reconstructed later are spooled to disk + // before this is even attempted -- see spoolOrDeliverMessage / spoolOrDeliverRemoval. } } + // ================================================================== + // the durable spool + // ================================================================== + + /// Deliveries that survive this process, because nothing else can reconstruct them. + /// + /// The in-memory queue in WearableConnection is the right home for a delivery that arrives + /// before a listener registers -- while the process lives. It is the wrong home for one that + /// arrives in a service process the OS then reclaims, and two kinds cannot be recovered + /// afterwards: + /// + /// - a one-shot message, which the Data Layer does not retain at all, and + /// - a data REMOVAL, whose item is by definition gone, so startup replay sees nothing to report. + /// + /// A replicated value needs none of this: the item is still published and startup replay finds + /// it. Nor does a reply-bearing request -- an answer produced after the peer's call has timed + /// out is not an answer -- so those keep going straight to the in-memory queue. + /// + /// Depending on the activity launch to bridge the gap was the bug: Android 10+ refuses a + /// background start, and the catch above then left the delivery in memory with nothing to + /// drain it. + private static final String SPOOL_PREFS = "cn1_wearable_spool"; + + private static final String SPOOL_SEQ_KEY = "seq"; + + /// Enough to absorb a burst while the app is away; past this the OLDEST go, because a spool + /// that grows without bound is its own failure and the newest state is the more useful. + private static final int SPOOL_MAX_ENTRIES = 256; + + private static final String SPOOL_MESSAGE = "m"; + + private static final String SPOOL_REMOVAL = "r"; + + /// Hands a one-shot message to a live listener, or writes it down for the next process. + static void spoolOrDeliverMessage(Context context, String path, byte[] payload) { + if (deliverableNow(context)) { + WearableConnection.deliverMessage(path, payload, 0); + return; + } + if (!spool(context, SPOOL_MESSAGE, path, payload)) { + // The spool is the durable half; if it could not be written, the in-memory queue is + // still better than dropping the message outright. + WearableConnection.deliverMessage(path, payload, 0); + } + } + + /// The same for a removal, whose item no longer exists to be replayed from. + static void spoolOrDeliverRemoval(Context context, String path) { + if (deliverableNow(context)) { + WearableConnection.deliverDataRemoved(path); + return; + } + if (!spool(context, SPOOL_REMOVAL, path, null)) { + WearableConnection.deliverDataRemoved(path); + } + } + + /// Whether this process can actually run app code, rather than merely hold it in a queue. + private static boolean deliverableNow(Context context) { + try { + if (!com.codename1.ui.Display.isInitialized()) { + return false; + } + } catch (Throwable notInitialized) { + return false; + } + // Initialized, so anything already spooled has to go FIRST -- otherwise a delivery written + // moments ago would arrive after one that happened later. + drainSpool(context); + return true; + } + + private static boolean spool(Context context, String kind, String path, byte[] payload) { + Context c = spoolContext(context); + if (c == null) { + return false; + } + try { + android.content.SharedPreferences prefs = + c.getSharedPreferences(SPOOL_PREFS, Context.MODE_PRIVATE); + long seq = prefs.getLong(SPOOL_SEQ_KEY, 0) + 1; + android.content.SharedPreferences.Editor edit = prefs.edit(); + edit.putLong(SPOOL_SEQ_KEY, seq); + // Zero-padded, because the drain replays in key order and the whole point is that a + // message arrives in the order it was sent. + edit.putString(spoolKey(seq), kind + "|" + encode(path) + "|" + + (payload == null ? "" : android.util.Base64.encodeToString( + payload, android.util.Base64.NO_WRAP))); + trimSpool(prefs, edit); + // commit(), and its result: the caller falls back to the in-memory queue when the write + // did not land, so an ignored false would silently lose exactly what this exists for. + return edit.commit(); + } catch (Throwable unavailable) { + return false; + } + } + + /// Replays everything written down, oldest first, and forgets it only once it is delivered. + static void drainSpool(Context context) { + Context c = spoolContext(context); + if (c == null) { + return; + } + java.util.List keys; + android.content.SharedPreferences prefs; + java.util.Map all; + try { + prefs = c.getSharedPreferences(SPOOL_PREFS, Context.MODE_PRIVATE); + all = prefs.getAll(); + if (all.isEmpty()) { + return; + } + keys = new ArrayList(); + for (String k : all.keySet()) { + if (!SPOOL_SEQ_KEY.equals(k)) { + keys.add(k); + } + } + if (keys.isEmpty()) { + return; + } + java.util.Collections.sort(keys); + } catch (Throwable unavailable) { + return; + } + // Removed BEFORE replay, and committed. A listener that throws must not leave the entry + // behind to be replayed on every launch from then on; a one-shot delivered once is the + // contract, and this is the only place that can hold to it. + android.content.SharedPreferences.Editor edit = prefs.edit(); + for (String k : keys) { + edit.remove(k); + } + try { + if (!edit.commit()) { + return; + } + } catch (Throwable unavailable) { + return; + } + for (String k : keys) { + Object raw = all.get(k); + if (!(raw instanceof String)) { + continue; + } + replaySpooled((String) raw); + } + } + + private static void replaySpooled(String record) { + int first = record.indexOf('|'); + int second = first < 0 ? -1 : record.indexOf('|', first + 1); + if (first < 0 || second < 0) { + return; + } + String kind = record.substring(0, first); + String path = decode(record.substring(first + 1, second)); + String encoded = record.substring(second + 1); + try { + if (SPOOL_REMOVAL.equals(kind)) { + WearableConnection.deliverDataRemoved(path); + return; + } + byte[] payload = encoded.length() == 0 ? new byte[0] + : android.util.Base64.decode(encoded, android.util.Base64.NO_WRAP); + WearableConnection.deliverMessage(path, payload, 0); + } catch (Throwable unreadable) { + // A record this build cannot parse is dropped rather than retried forever. + } + } + + private static void trimSpool(android.content.SharedPreferences prefs, + android.content.SharedPreferences.Editor edit) { + java.util.List keys = new ArrayList(); + for (String k : prefs.getAll().keySet()) { + if (!SPOOL_SEQ_KEY.equals(k)) { + keys.add(k); + } + } + if (keys.size() < SPOOL_MAX_ENTRIES) { + return; + } + java.util.Collections.sort(keys); + int drop = keys.size() - SPOOL_MAX_ENTRIES + 1; + for (int i = 0; i < drop; i++) { + edit.remove(keys.get(i)); + android.util.Log.w("CN1Wearable", "wearable spool is full at " + SPOOL_MAX_ENTRIES + + " entries; dropping the oldest undelivered entry " + keys.get(i)); + } + } + + private static String spoolKey(long seq) { + String digits = Long.toString(seq); + StringBuilder sb = new StringBuilder("e"); + for (int i = digits.length(); i < 18; i++) { + sb.append('0'); + } + return sb.append(digits).toString(); + } + + private static Context spoolContext(Context context) { + Context c = context; + if (c == null) { + c = serviceContext; + } + if (c == null) { + CN1WearableBridge b = current; + c = b == null ? null : b.context; + } + return c; + } + static void noteServiceContext(Context context) { if (context == null || serviceContext != null) { return; @@ -2823,7 +3039,10 @@ static boolean deliverRemovalIfStampUnchanged(String path, String expected) { // listener ever drains leaves the app's persisted state holding a value the peer // deleted, with nothing left to correct it. ensureAppRunning(); - WearableConnection.deliverDataRemoved(path); + // Spooled rather than queued when nothing can run it: the deleted item is gone from + // startup replay, so a removal lost with the service process leaves the app holding a + // value the peer deleted, with nothing left to correct it. + spoolOrDeliverRemoval(null, path); return true; } } @@ -2941,7 +3160,7 @@ static void announceResolvedRemoval(String path, String before) { // queued with no lifecycle to drain it -- and the deleted item is absent from // startup replay, so the app's persisted state kept a value the peer removed. ensureAppRunning(); - WearableConnection.deliverDataRemoved(path); + spoolOrDeliverRemoval(null, path); } } else if (!isRemovalAnnounced(before)) { // A sentinel means this logical removal has already been reported, by an earlier @@ -3661,6 +3880,7 @@ static boolean claimTransfer(Context context, Uri uri, long sequence) { // would treat the two as one stream and discard the second sender's file whenever its // sequence did not happen to exceed the first's. String key = uri.getHost() + ":" + uri.getPath(); + boolean durable; synchronized (transferClaims) { String previous = transferClaims.get(key); if (previous == null) { @@ -3725,10 +3945,23 @@ static void confirmTransferDelivered(Context context, Uri uri, long sequence, // and since every transfer gets a unique sequence-suffixed URI the store would grow // without bound. Pruning needs a clock that measures elapsed time; the sequence is not // one. - persistClaim(context, key, sequence + "|" + durable = persistClaim(context, key, sequence + "|" + (uri.getHost() == null ? "" : uri.getHost()) + "|" + System.currentTimeMillis()); } + if (!durable) { + // No acknowledgement without a durable claim. The acknowledgement is what lets the + // SENDER retire the item, and retiring it while this device has no claim on disk means + // a restart here finds neither the claim nor -- eventually -- the item, so the one-shot + // transfer is simply lost. Staying silent costs the sender an item kept until the hard + // cap and buys a redelivery this device will accept, which is the recoverable side of + // the trade. commit() returning false is rare (a full or unwritable store) and is + // exactly when this matters. + android.util.Log.w("CN1Wearable", "transfer claim for " + key + + " was not written; withholding the acknowledgement so the sender keeps the" + + " item and can redeliver it"); + return; + } publishTransferAck(context, uri); } @@ -3921,15 +4154,19 @@ private static boolean claimExpired(String recorded) { *

Bounded by the same window the sender sweeps its transfers on: once the item itself is * gone there is nothing left to re-deliver, so the claim has no one to stop and keeping it * would grow this store without limit.

+ * + * @return whether the claim actually reached disk. The caller publishes the acknowledgement + * only on true: a false says the sender must keep the item, because this device has + * nothing to stop a redelivery with and losing the transfer is the unrecoverable side. */ - private static void persistClaim(Context context, String key, String stamp) { + private static boolean persistClaim(Context context, String key, String stamp) { Context c = context; if (c == null) { CN1WearableBridge b = current; c = b == null ? null : b.context; } if (c == null) { - return; + return false; } try { android.content.SharedPreferences prefs = @@ -3943,7 +4180,13 @@ private static void persistClaim(Context context, String key, String stamp) { // was still published, so the next startup replay handed the app the same file again. // // Called from the confirmation path on a Data Layer worker, never the main thread. - edit.commit(); + // + // The RESULT matters, and ignoring it undid half the point of using commit(): a full or + // unwritable store returns false, and the caller then acknowledged a transfer this + // device has no durable claim on. + if (!edit.commit()) { + return false; + } // NO inline prune. Writing one claim says nothing about whether OTHER items are still // published, and the sender retries a failed deletion indefinitely -- so age-pruning // here could drop a live transfer's claim while recording an unrelated one, and the @@ -3951,8 +4194,11 @@ private static void persistClaim(Context context, String key, String stamp) { // goes through a replay pass that has just refreshed what still exists; the // maintenance timer below is what keeps the store bounded. scheduleClaimPrune(c); + return true; } catch (Throwable unavailable) { - // Best effort: the in-memory claim still holds for this process. + // The in-memory claim still holds for this process, but nothing is on disk -- so this + // is a failure by the only measure the caller cares about. + 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 b81170830d4..72838adccc5 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 @@ -151,10 +151,14 @@ private void handleMessageReceived(MessageEvent event) { } if (path.startsWith(CN1WearableBridge.messagePath() + "/")) { ensureAppRunning(); - WearableConnection.deliverMessage( + // Through the spool, not straight into the in-memory queue. A one-shot message is not + // retained by the Data Layer, so if this service process is reclaimed before the user + // opens the app there is nothing left to replay -- and the activity launch above cannot + // be relied on to prevent that, because Android 10+ refuses a background start. + CN1WearableBridge.spoolOrDeliverMessage(getApplicationContext(), CN1WearableBridge.decode( path.substring(CN1WearableBridge.messagePath().length() + 1)), - event.getData(), 0); + event.getData()); } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java index f1f0e6128c6..577b949c8cf 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java @@ -139,6 +139,45 @@ void writeThroughDoesNotEntitleARootThatTouchesNoSensor() { assertFalse(builder.reachesHealth(reachable("com/acme/WatchApp", "com/acme/Ui"))); } + /** + * A screen reached only through a generated registry still counts. + * + *

Both stubs install the route dispatcher and the annotation bootstraps, and each names + * every target it can dispatch to -- so ParparVM retains a HealthKit screen reached only by + * route string, while a walk starting at the lifecycle cannot see it. That target then shipped + * without its entitlement and had its authorization request refused.

+ */ + @Test + void generatedRegistriesAreRootsOfTheWalk(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tmp) + throws Exception { + IPhoneBuilder builder = new IPhoneBuilder(); + java.io.File classes = tmp.toFile(); + assertTrue(builder.installedRegistryRoots(classes).isEmpty(), + "a project with no generated registry contributes no extra roots"); + + write(classes, "com/codename1/router/generated/Routes.class"); + write(classes, "cn1app/MapperBootstrap.class"); + java.util.List roots = builder.installedRegistryRoots(classes); + assertTrue(roots.contains("com/codename1/router/generated/Routes"), roots.toString()); + assertTrue(roots.contains("cn1app/MapperBootstrap"), roots.toString()); + // The health binding factory must never be a root: it names every listener, so rooting + // there would make both targets reach health whatever their lifecycle does. + for (String root : roots) { + assertFalse(root.contains("CN1HealthListenerBindings"), root); + } + } + + private static void write(java.io.File dir, String relative) throws Exception { + java.io.File f = new java.io.File(dir, relative.replace('/', java.io.File.separatorChar)); + f.getParentFile().mkdirs(); + java.io.FileOutputStream out = new java.io.FileOutputStream(f); + try { + out.write(new byte[] {(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE}); + } finally { + out.close(); + } + } + /** A root that reaches no listener binds none, rather than falling back to all of them. */ @Test void aRootThatReachesNoListenerBindsNone() { From 539cf452b722f9ae40fb9dda13cc8a918f0f9e09 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:23:01 +0700 Subject: [PATCH 219/250] Wearables: a real watch readiness flag, a serialized spool, and app-only native shaking The watch readiness predicate proved nothing. It asked `[CodenameOne_GLViewController instance] != nil` on the reasoning that the implementation creates the view controller during Display.init -- but that accessor LAZY-ALLOCATES, so asking made it non-nil and the answer was yes from the moment the runtime flag was set. A phase forwarded in that window reached a half-built VM, or delivered stop() before init() and start() had ever run. The generated bootstrap now publishes readiness itself, after the watch stub's main returns: Display.init has completed by then, the implementation exists and the EDT is running. Spool sequence allocation is serialized. The message worker and the data worker are different threads, and two of them reading the same seq before either committed wrote the same key -- the later commit then overwrote the earlier record and lost a delivery that has no other copy. Allocation, trim, commit and take are one critical section now; replay stays outside it, because a listener runs app code and must not block the next write. Native registrations are filtered per translation root again, and this time only for APP natives. My earlier revert said the walk could not see through the core jar -- that was wrong: the app zip, iOSPort.jar and the ParparVM Java API are all unzipped into classesDir before any of this runs, so the walk has the whole translation classpath. What is true is that com.codename1 plumbing gets reached in ways a constant-pool walk cannot always see, and an absent registration there does not fail the build, it silently disables a service. Framework natives are therefore kept unconditionally and only the developer's own natives are shaken out -- which is the entire case, since it is their Objective-C that imports an iOS-only SDK. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/CN1WatchRuntime.m | 26 +++- .../com/codename1/builders/IPhoneBuilder.java | 64 ++++++++- .../builders/WatchNativeBuilder.java | 9 +- .../builders/wearable/CN1WearableBridge.java | 135 ++++++++++-------- .../IPhoneBuilderHealthListenerScopeTest.java | 50 +++++++ 5 files changed, 218 insertions(+), 66 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1WatchRuntime.m b/Ports/iOSPort/nativeSources/CN1WatchRuntime.m index 3c685fce280..a5d39a82810 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchRuntime.m +++ b/Ports/iOSPort/nativeSources/CN1WatchRuntime.m @@ -184,10 +184,20 @@ static void cn1WatchQueuePhase(int phase) { /// cn1_watch_runtime_start sets its flag and then hands off to a THREAD that has yet to reach /// Display.init, so the flag is true for a window in which IOSImplementation.instance is still /// null. Forwarding a phase into that window dereferences null inside translated code, on a thread -/// with no Java frame to unwind to. The view controller is created during Display.init by the -/// implementation itself, so its existence is evidence the implementation exists. +/// with no Java frame to unwind to. +/// +/// Published by the app's own bootstrap, once its stub's main has returned -- Display.init has +/// completed by then, so the implementation exists and the EDT is running. +/// +/// It used to be inferred from `[CodenameOne_GLViewController instance] != nil` on the reasoning +/// that the implementation creates the view controller during Display.init. That accessor +/// LAZY-ALLOCATES: asking made it non-nil, so the test answered yes from the moment the runtime +/// flag was set and proved nothing at all. A phase forwarded in that window reached a half-built +/// VM, or delivered stop() before init() and start() had ever run. +static volatile BOOL cn1WatchJavaLifecycleReady = NO; + static BOOL cn1WatchJavaReady(void) { - return cn1WatchRuntimeStarted && [CodenameOne_GLViewController instance] != nil; + return cn1WatchJavaLifecycleReady; } static void cn1WatchDeliverPhase(int phase) { @@ -200,6 +210,16 @@ static void cn1WatchDeliverPhase(int phase) { } } +/// Called by the generated bootstrap once the watch stub's main has returned. +/// +/// Anything queued while the VM was coming up is handed over immediately: the paint pump is the +/// other drain and it is stopped while the watch is in the background, which is exactly when a +/// queued background transition is waiting. +void cn1_watch_runtime_markJavaReady(void) { + cn1WatchJavaLifecycleReady = YES; + cn1WatchReplayPendingPhase(); +} + /// Hands over, in order, every phase the app could not be told about yet. /// /// Called from the paint pump AND from each incoming transition. The pump alone is not enough: it 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 ee9626e0e49..a604114095e 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 @@ -421,6 +421,62 @@ void resolveHealthUsagePerRoot(BuildRequest request, File classesDir) { + ", watch=" + watchRootReachesHealth); } + /// The native-interface registrations one translation root can reach. + /// + /// Every `NativeLookup.register(X.class, XStub.class)` is a hard reference and XStub holds one + /// to the generated implementation, so a stub installing the app-wide list roots every native + /// implementation in its own translation. With a distinct watchMain that meant a phone-only + /// native interface had its Objective-C staged and compiled for watchOS, where a UIKit import + /// or an iOS-only SDK is a build failure in code the watch never calls. + /// + /// The walk is complete for this question: the app zip, iOSPort.jar and the ParparVM Java API + /// are all unzipped into classesDir before anything here runs, so the whole translation + /// classpath is on disk in one directory and nothing is invisible to it. + /// + /// FRAMEWORK natives are kept regardless. com.codename1 plumbing is reached in ways a + /// constant-pool walk cannot always see -- a runtime lookup behind an interface, an + /// implementation picked at Display.init -- and an absent registration there does not fail the + /// build, it silently disables a service at runtime, which is far harder to attribute than the + /// compile error this exists to prevent. Only APP natives are shaken out, and they are the + /// whole of the case: the developer's own Objective-C is what imports an iOS-only SDK. + String nativeRegistrationsReachableFrom(String registrations, + java.util.Set reachable) { + if (reachable == null || registrations == null || registrations.length() == 0) { + return registrations; + } + StringBuilder out = new StringBuilder(); + for (String line : registrations.split("\n")) { + String registered = registeredInterfaceName(line); + String internal = registered == null ? null : registered.replace('.', '/'); + // A line that registers nothing is passed through untouched rather than guessed at. + boolean keep = internal == null + || internal.startsWith("com/codename1/") + || reachable.contains(internal); + if (keep) { + out.append(line).append('\n'); + } else { + log("[watchNative] not registering " + registered + " in the watch stub: the watch" + + " translation root does not reach it"); + } + } + return out.toString(); + } + + /// The interface named by one `NativeLookup.register(...)` line, or null if it is not one. + private String registeredInterfaceName(String line) { + final String open = "NativeLookup.register("; + int start = line.indexOf(open); + if (start < 0) { + return null; + } + start += open.length(); + int end = line.indexOf(".class", start); + if (end <= start) { + return null; + } + return line.substring(start, end).trim(); + } + /// The generated registries a stub installs, as internal names, limited to the ones this /// project actually produced. /// @@ -2562,9 +2618,11 @@ public void usesClassMethod(String cls, String method) { // point actually reaches. if (watchNativeBuilder.needsOwnTranslation()) { watchNativeBuilder.writeWatchStubSource(request, stubSource, buildVersion, - registerNativeImplementationsAndCreateStubs( - new URLClassLoader(new URL[]{codenameOneJar.toURI().toURL()}), - stubSource, classesDir), + nativeRegistrationsReachableFrom( + registerNativeImplementationsAndCreateStubs( + new URLClassLoader(new URL[]{codenameOneJar.toURI().toURL()}), + stubSource, classesDir), + watchReachableClasses), iosMode, svgRegistryInstall, watchHealthBindingsInstall, routeDispatcherInstallSource(sourceZip, " "), annotationFrameworksInstallSource(sourceZip, " ")); 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 e3917f69e68..371892a1966 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 @@ -591,7 +591,8 @@ void writeWatchEntry(BuildRequest request, File appSrcDir) throws IOException { .append("extern void cn1_watch_runtime_pointerDragged(int x, int y);\n") .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("extern void cn1_watch_runtime_willEnterForeground(void);\n") + .append("extern void cn1_watch_runtime_markJavaReady(void);\n\n") .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) @@ -599,6 +600,12 @@ void writeWatchEntry(BuildRequest request, File appSrcDir) throws IOException { .append("void cn1_watch_app_main(void) {\n") .append(" ").append(mainStub) .append("_main___java_lang_String_1ARRAY(getThreadLocalData(), JAVA_NULL);\n") + // Display.init has returned by here, so IOSImplementation.instance exists and the EDT is + // running. THIS is the readiness signal: the runtime used to infer it from + // [CodenameOne_GLViewController instance] != nil, and that accessor lazily allocates the + // singleton, so the test made itself true the moment the runtime flag was set and a + // lifecycle phase could be forwarded into a half-built VM. + .append(" cn1_watch_runtime_markJavaReady();\n") .append("}\n\n") .append("// Watch lifecycle entry class (mangled FQN): ").append(m).append("\n") .append("void cn1_watch_bootstrap(void) { cn1_watch_runtime_start(\"") 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 4a74c8df50e..3641d337da6 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 @@ -1325,6 +1325,14 @@ static void ensureAppRunning() { /// that grows without bound is its own failure and the newest state is the more useful. private static final int SPOOL_MAX_ENTRIES = 256; + /// Guards sequence allocation, trimming, commit and drain together. + /// + /// Not decoration: the message worker and the data worker are different threads, and two of + /// them reading the same seq before either commits produced the same key -- the later write + /// then overwrote the earlier record and lost a delivery that has no other copy anywhere. A + /// SharedPreferences editor is not a transaction, so the read-modify-write has to be one. + private static final Object SPOOL_LOCK = new Object(); + private static final String SPOOL_MESSAGE = "m"; private static final String SPOOL_REMOVAL = "r"; @@ -1373,75 +1381,84 @@ private static boolean spool(Context context, String kind, String path, byte[] p if (c == null) { return false; } - try { - android.content.SharedPreferences prefs = - c.getSharedPreferences(SPOOL_PREFS, Context.MODE_PRIVATE); - long seq = prefs.getLong(SPOOL_SEQ_KEY, 0) + 1; - android.content.SharedPreferences.Editor edit = prefs.edit(); - edit.putLong(SPOOL_SEQ_KEY, seq); - // Zero-padded, because the drain replays in key order and the whole point is that a - // message arrives in the order it was sent. - edit.putString(spoolKey(seq), kind + "|" + encode(path) + "|" - + (payload == null ? "" : android.util.Base64.encodeToString( - payload, android.util.Base64.NO_WRAP))); - trimSpool(prefs, edit); - // commit(), and its result: the caller falls back to the in-memory queue when the write - // did not land, so an ignored false would silently lose exactly what this exists for. - return edit.commit(); - } catch (Throwable unavailable) { - return false; + synchronized (SPOOL_LOCK) { + try { + android.content.SharedPreferences prefs = + c.getSharedPreferences(SPOOL_PREFS, Context.MODE_PRIVATE); + long seq = prefs.getLong(SPOOL_SEQ_KEY, 0) + 1; + android.content.SharedPreferences.Editor edit = prefs.edit(); + edit.putLong(SPOOL_SEQ_KEY, seq); + // Zero-padded, because the drain replays in key order and the whole point is that + // a message arrives in the order it was sent. + edit.putString(spoolKey(seq), kind + "|" + encode(path) + "|" + + (payload == null ? "" : android.util.Base64.encodeToString( + payload, android.util.Base64.NO_WRAP))); + trimSpool(prefs, edit); + // commit(), and its result: the caller falls back to the in-memory queue when the + // write did not land, so an ignored false would silently lose exactly what this + // exists for. + return edit.commit(); + } catch (Throwable unavailable) { + return false; + } } } - /// Replays everything written down, oldest first, and forgets it only once it is delivered. + /// Replays everything written down, oldest first, and forgets it only once it is taken. static void drainSpool(Context context) { + for (String record : takeSpooled(context)) { + // Outside the lock. A listener runs arbitrary app code, and holding the spool across + // it would block every worker trying to write the next delivery down. + replaySpooled(record); + } + } + + /// Removes every spooled record and returns it, oldest first. + /// + /// Taken BEFORE replay, and committed. A listener that throws must not leave the entry behind + /// to be replayed on every launch from then on: delivered once is the contract for a one-shot, + /// and this is the only place that can hold to it. + private static java.util.List takeSpooled(Context context) { + java.util.List records = new ArrayList(); Context c = spoolContext(context); if (c == null) { - return; + return records; } - java.util.List keys; - android.content.SharedPreferences prefs; - java.util.Map all; - try { - prefs = c.getSharedPreferences(SPOOL_PREFS, Context.MODE_PRIVATE); - all = prefs.getAll(); - if (all.isEmpty()) { - return; - } - keys = new ArrayList(); - for (String k : all.keySet()) { - if (!SPOOL_SEQ_KEY.equals(k)) { - keys.add(k); + synchronized (SPOOL_LOCK) { + try { + android.content.SharedPreferences prefs = + c.getSharedPreferences(SPOOL_PREFS, Context.MODE_PRIVATE); + java.util.Map all = prefs.getAll(); + java.util.List keys = new ArrayList(); + for (String k : all.keySet()) { + if (!SPOOL_SEQ_KEY.equals(k)) { + keys.add(k); + } } + if (keys.isEmpty()) { + return records; + } + java.util.Collections.sort(keys); + android.content.SharedPreferences.Editor edit = prefs.edit(); + for (String k : keys) { + edit.remove(k); + } + if (!edit.commit()) { + // Still on disk, so leave it there and try again next time rather than + // replaying records this process may fail to remove afterwards. + return new ArrayList(); + } + for (String k : keys) { + Object raw = all.get(k); + if (raw instanceof String) { + records.add((String) raw); + } + } + } catch (Throwable unavailable) { + return new ArrayList(); } - if (keys.isEmpty()) { - return; - } - java.util.Collections.sort(keys); - } catch (Throwable unavailable) { - return; - } - // Removed BEFORE replay, and committed. A listener that throws must not leave the entry - // behind to be replayed on every launch from then on; a one-shot delivered once is the - // contract, and this is the only place that can hold to it. - android.content.SharedPreferences.Editor edit = prefs.edit(); - for (String k : keys) { - edit.remove(k); - } - try { - if (!edit.commit()) { - return; - } - } catch (Throwable unavailable) { - return; - } - for (String k : keys) { - Object raw = all.get(k); - if (!(raw instanceof String)) { - continue; - } - replaySpooled((String) raw); } + return records; } private static void replaySpooled(String record) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java index 577b949c8cf..7af6962f4b8 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java @@ -178,6 +178,56 @@ private static void write(java.io.File dir, String relative) throws Exception { } } + /** + * A phone-only app native is not registered in the watch stub. + * + *

Each registration is a hard reference and the stub it names holds one to the native + * implementation, so the app-wide list rooted every native implementation in the watch + * translation -- and a phone-only one had its Objective-C compiled for watchOS, where a UIKit + * import is a build failure in code the watch never calls.

+ */ + @Test + void theWatchStubDropsAppNativesItCannotReach() { + IPhoneBuilder builder = new IPhoneBuilder(); + String all = " NativeLookup.register(com.acme.PhoneNative.class," + + " com.acme.PhoneNativeStub.class);\n" + + " NativeLookup.register(com.acme.WatchNative.class," + + " com.acme.WatchNativeStub.class);\n"; + + String watch = builder.nativeRegistrationsReachableFrom(all, + reachable("com/acme/WatchApp", "com/acme/WatchNative")); + assertTrue(watch.contains("com.acme.WatchNative.class"), watch); + assertFalse(watch.contains("com.acme.PhoneNative.class"), + "the watch must not root the phone's native implementation: " + watch); + } + + /** + * Framework natives are kept whether the walk reaches them or not. + * + *

com.codename1 plumbing is reached in ways a constant-pool walk cannot always see, and an + * absent registration there does not fail the build -- it silently disables a service at + * runtime. Dropping one is how the watch screenshot suite ended up running with no transport, + * which is exactly the failure mode this exemption exists to make impossible.

+ */ + @Test + void frameworkNativesAreNeverDropped() { + IPhoneBuilder builder = new IPhoneBuilder(); + String all = " NativeLookup.register(com.codename1.io.websocket.WebSocketNativeImpl" + + ".class, com.codename1.io.websocket.WebSocketNativeImplStub.class);\n"; + assertEquals(all, builder.nativeRegistrationsReachableFrom(all, + reachable("com/acme/WatchApp"))); + } + + /** One translation registers everything, exactly as before. */ + @Test + void oneTranslationRegistersEveryNative() { + IPhoneBuilder builder = new IPhoneBuilder(); + String all = " NativeLookup.register(com.acme.PhoneNative.class," + + " com.acme.PhoneNativeStub.class);\n"; + assertEquals(all, builder.nativeRegistrationsReachableFrom(all, null)); + assertEquals("", builder.nativeRegistrationsReachableFrom("", reachable("com/acme/X"))); + } + /** A root that reaches no listener binds none, rather than falling back to all of them. */ @Test void aRootThatReachesNoListenerBindsNone() { From 9f4e301526dd093ec4ebd0880e45ecff31cef23c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:54:46 +0700 Subject: [PATCH 220/250] Wearables: mirror only the Swift package products the watch sources import Copying the phone's whole dependency set onto the watch target made Xcode resolve and build every one of them for watchOS, so an iOS-only package used solely by the phone broke the watch build for code the watch never references. No SDK check is possible for a package -- Package.swift is not resolved until long after the project is generated -- but the staged watch tree is the complete set of sources the target compiles, so a product no file in it imports cannot be needed. A skip is logged with the product name, because a module named differently from its product would otherwise fail to link with nothing pointing at the cause. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/WatchNativeBuilder.java | 49 +++++++++++++++++-- .../builders/WatchNativeBuilderTest.java | 6 +++ 2 files changed, 50 insertions(+), 5 deletions(-) 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 371892a1966..ace708f5638 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 @@ -1862,13 +1862,52 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // conceptually its own list even though the pbxproj format would tolerate one object in // two. // - // No SDK check is possible here, unlike the system frameworks above. A package declares its - // supported platforms in its own Package.swift, which is not resolved until xcodebuild runs. - // So this mirrors the declaration and says so: a package with no watchOS support fails with - // Xcode naming the product, which is a better outcome than a link error naming nothing. - s.append("app_target.package_product_dependencies.to_a.each do |dep|\n") + // Mirrored only when the WATCH sources actually import the product. + // + // No SDK check is possible here, unlike the system frameworks above: a package declares its + // supported platforms in its own Package.swift, which xcodebuild does not resolve until + // long after this runs. Copying the phone's whole dependency set across therefore made + // Xcode resolve and build every one of them for watchOS -- so an iOS-only package used + // solely by the phone broke the watch build outright, for code the watch never references. + // + // The staged watch tree is the evidence that IS available. It is the complete set of + // sources this target compiles, so a product no file in it imports cannot be needed, and a + // product one of them does import is needed whatever the phone uses. Both import spellings + // are matched, plus the bridging-header form. + // + // If a watch source needs a product under a module name that differs from the product name + // -- legal, and rare -- the skip is logged with the name, so the link error that follows + // has something in the build output pointing at it. + s.append("watch_import_src = ") + .append(watchSources.isEmpty() ? "nil\n" + : "File.join(File.dirname(project_file), watch_group_path)\n") + .append("watch_import_text = nil\n") + .append("if watch_import_src && File.directory?(watch_import_src)\n") + .append(" watch_import_text = ''\n") + .append(" Dir.glob(File.join(watch_import_src, '**', '*.{h,m,mm,c,cpp,cc,swift}'))" + + ".each do |f|\n") + .append(" begin\n") + .append(" watch_import_text << File.read(f)\n") + .append(" rescue StandardError\n") + .append(" next\n") + .append(" end\n") + .append(" end\n") + .append("end\n") + .append("app_target.package_product_dependencies.to_a.each do |dep|\n") .append(" name = dep.respond_to?(:product_name) ? dep.product_name : nil\n") .append(" next unless name\n") + // watch_import_text is nil when the watch shares the phone's translation, and then + // the watch compiles the phone's sources and needs the phone's packages. + .append(" if watch_import_text\n") + .append(" used = watch_import_text.include?(\"import #{name}\") || " + + "watch_import_text.include?(\"<#{name}/\") || " + + "watch_import_text.include?(\"\\\"#{name}/\")\n") + .append(" unless used\n") + .append(" puts \"[watchNative] not linking Swift package product #{name} into " + + "the watch target: no staged watch source imports it\"\n") + .append(" next\n") + .append(" end\n") + .append(" end\n") .append(" next if watch_target.package_product_dependencies.any? { |d| " + "d.respond_to?(:product_name) && d.product_name == name }\n") .append(" mirrored = xcproj.new(" 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 65792225623..1ca6dc9c86a 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 @@ -690,6 +690,12 @@ void swiftPackageProductsReachTheWatchTarget(@TempDir Path tmp) throws Exception assertTrue(ruby.contains("app_target.package_product_dependencies.to_a.each"), "the phone's package products are the source of the mirror: " + ruby); + // Mirrored only when a staged watch source imports the product. Copying the phone's whole + // dependency set made Xcode resolve and build every one of them for watchOS, so an iOS-only + // package used solely by the phone broke the watch build for code the watch never touches. + assertTrue(ruby.contains("watch_import_text"), ruby); + assertTrue(ruby.contains("no staged watch source imports it"), + "a skip has to say so, or the link error that follows names nothing: " + ruby); // Both halves are required. Listing the product on the target is what makes Xcode resolve // the package for it; the frameworks-phase build file is what links it. Either alone // produces a project that still fails, and differently. From 37559394a213e1a2c36b55c5b8bfdb28bd3a1191 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:54:58 +0700 Subject: [PATCH 221/250] Wearables: publish watch readiness where it can run, and match imports on module boundaries The readiness call could never execute. The stub's main reaches Display.init -> postInit -> IOSNative.initVM, whose watch branch blocks its thread forever -- exactly as UIApplicationMain does on the phone -- so a call placed after that main was dead code. The flag stayed false and every background and foreground transition queued for ever, which is the stop()/start() the watch app was missing in the first place. It is published from inside that branch now, immediately after the IOSImplementation callback that installs the lifecycle: safe that early because both forwarders serial-dispatch onto the EDT, so a released phase queues behind the app start the callback just scheduled. The Swift package import test matched substrings. A product Foo looked used by a source importing FooBar, mirroring an unrelated iOS-only package onto the watch -- the exact failure the gate exists to prevent. It matches module boundaries now, and it also reads Swift's declaration-scoped form: `import struct Foo.Bar` names the module Foo just as `import Foo` does, and missing it dropped a product the source genuinely needs. Sixteen cases checked against the generated regex, including both reviewers' examples. And `durable` was declared in claimTransfer instead of confirmTransferDelivered, so the generated bridge did not compile for any Android app that references com.codename1.wearable. My syntax check missed it because javac stops at 100 errors by default and the real one was past the cap, behind the expected noise of unresolved Android and Play services types. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/CN1WatchRuntime.m | 8 ++-- Ports/iOSPort/nativeSources/IOSNative.m | 12 ++++++ .../builders/WatchNativeBuilder.java | 40 +++++++++++++------ .../builders/wearable/CN1WearableBridge.java | 2 +- .../builders/WatchNativeBuilderTest.java | 28 +++++++++++++ 5 files changed, 73 insertions(+), 17 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1WatchRuntime.m b/Ports/iOSPort/nativeSources/CN1WatchRuntime.m index a5d39a82810..1738ffc2aa5 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchRuntime.m +++ b/Ports/iOSPort/nativeSources/CN1WatchRuntime.m @@ -186,8 +186,10 @@ static void cn1WatchQueuePhase(int phase) { /// null. Forwarding a phase into that window dereferences null inside translated code, on a thread /// with no Java frame to unwind to. /// -/// Published by the app's own bootstrap, once its stub's main has returned -- Display.init has -/// completed by then, so the implementation exists and the EDT is running. +/// Published from IOSNative.initVM's watch branch, immediately after the IOSImplementation +/// callback that installs the lifecycle and schedules the app start. That branch then blocks its +/// thread forever, mirroring UIApplicationMain, so there is no later point to publish from: a call +/// placed after the stub's main would never run. /// /// It used to be inferred from `[CodenameOne_GLViewController instance] != nil` on the reasoning /// that the implementation creates the view controller during Display.init. That accessor @@ -210,7 +212,7 @@ static void cn1WatchDeliverPhase(int phase) { } } -/// Called by the generated bootstrap once the watch stub's main has returned. +/// Called from IOSNative.initVM's watch branch once the Java lifecycle callback has run. /// /// Anything queued while the VM was coming up is handed over immediately: the paint pump is the /// other drain and it is stopped while the watch is in the background, which is exactly when a diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 6fec0fec057..8e000c1ec0e 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -542,6 +542,18 @@ void com_codename1_impl_ios_IOSNative_initVM__(CN1_THREAD_STATE_MULTI_ARG JAVA_O // thread and the CN1WatchHost paint pump keep running; the EDT runs the app. extern JAVA_VOID com_codename1_impl_ios_IOSImplementation_callback__(struct ThreadLocalData* threadStateData); com_codename1_impl_ios_IOSImplementation_callback__(threadStateData); + // HERE, and not after the stub's main returns: this thread never returns. The callback above + // is the point at which IOSImplementation exists and its lifecycle is installed, so it is the + // only honest place to say the Java side can be told about a background or foreground + // transition. Publishing it from the generated bootstrap after the stub's main looked + // equivalent and was dead code -- the loop below is entered first and never left, so the flag + // stayed false and every transition queued forever, which is the stop()/start() the watch app + // was missing in the first place. + // + // 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. + extern void cn1_watch_runtime_markJavaReady(void); + cn1_watch_runtime_markJavaReady(); while (1) { [NSThread sleepForTimeInterval:3600]; } 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 ace708f5638..81428331081 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 @@ -591,8 +591,7 @@ void writeWatchEntry(BuildRequest request, File appSrcDir) throws IOException { .append("extern void cn1_watch_runtime_pointerDragged(int x, int y);\n") .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") - .append("extern void cn1_watch_runtime_markJavaReady(void);\n\n") + .append("extern void cn1_watch_runtime_willEnterForeground(void);\n\n") .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) @@ -600,12 +599,10 @@ void writeWatchEntry(BuildRequest request, File appSrcDir) throws IOException { .append("void cn1_watch_app_main(void) {\n") .append(" ").append(mainStub) .append("_main___java_lang_String_1ARRAY(getThreadLocalData(), JAVA_NULL);\n") - // Display.init has returned by here, so IOSImplementation.instance exists and the EDT is - // running. THIS is the readiness signal: the runtime used to infer it from - // [CodenameOne_GLViewController instance] != nil, and that accessor lazily allocates the - // singleton, so the test made itself true the moment the runtime flag was set and a - // lifecycle phase could be forwarded into a half-built VM. - .append(" cn1_watch_runtime_markJavaReady();\n") + // Nothing after the call above: it does not return. Display.init -> postInit -> + // IOSNative.initVM blocks this thread forever on the watch, exactly as UIApplicationMain + // does on the phone, so readiness is published from inside initVM's watch branch -- right + // after the lifecycle callback that makes it true. A call placed here would never run. .append("}\n\n") .append("// Watch lifecycle entry class (mangled FQN): ").append(m).append("\n") .append("void cn1_watch_bootstrap(void) { cn1_watch_runtime_start(\"") @@ -1872,8 +1869,18 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // // The staged watch tree is the evidence that IS available. It is the complete set of // sources this target compiles, so a product no file in it imports cannot be needed, and a - // product one of them does import is needed whatever the phone uses. Both import spellings - // are matched, plus the bridging-header form. + // product one of them does import is needed whatever the phone uses. + // + // Matched on MODULE BOUNDARIES, not as a substring. A raw include? made a product named Foo + // look used by a source importing FooBar, which mirrored an unrelated iOS-only package onto + // the watch and broke the build in the exact way this gate exists to prevent. The name must + // be followed by end-of-token: a newline, a dot (Foo.Bar), a semicolon, or the closing + // bracket of an Objective-C import. + // + // Swift's declaration-scoped form counts too. `import struct Foo.Bar` names the module Foo + // just as `import Foo` does, and reading only the unqualified form dropped a product the + // source genuinely needs -- the opposite failure, and the worse one, because it breaks a + // build that should work. // // If a watch source needs a product under a module name that differs from the product name // -- legal, and rare -- the skip is logged with the name, so the link error that follows @@ -1899,9 +1906,16 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // watch_import_text is nil when the watch shares the phone's translation, and then // the watch compiles the phone's sources and needs the phone's packages. .append(" if watch_import_text\n") - .append(" used = watch_import_text.include?(\"import #{name}\") || " - + "watch_import_text.include?(\"<#{name}/\") || " - + "watch_import_text.include?(\"\\\"#{name}/\")\n") + .append(" q = Regexp.escape(name)\n") + // Swift: `import Foo`, `import Foo.Bar`, and the declaration-scoped + // `import struct Foo.Bar` -- the optional kind keyword is the whole difference. + // Objective-C / C: `#import `, `#import "Foo/Foo.h"`, `@import Foo;`. + .append(" swift_import = /^\\s*(?:@_exported\\s+)?import\\s+" + + "(?:typealias|struct|class|enum|protocol|let|var|func)?\\s*" + + "#{q}(?:\\.|\\s|$)/\n") + .append(" objc_import = /(?:@import\\s+#{q}\\s*;|[<\\\"]#{q}\\/)/\n") + .append(" used = !(watch_import_text =~ swift_import).nil? || " + + "!(watch_import_text =~ objc_import).nil?\n") .append(" unless used\n") .append(" puts \"[watchNative] not linking Swift package product #{name} into " + "the watch target: no staged watch source imports it\"\n") 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 3641d337da6..adcf19a43ba 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 @@ -3897,7 +3897,6 @@ static boolean claimTransfer(Context context, Uri uri, long sequence) { // would treat the two as one stream and discard the second sender's file whenever its // sequence did not happen to exceed the first's. String key = uri.getHost() + ":" + uri.getPath(); - boolean durable; synchronized (transferClaims) { String previous = transferClaims.get(key); if (previous == null) { @@ -3954,6 +3953,7 @@ static void confirmTransferDelivered(Context context, Uri uri, long sequence, return; } String key = uri.getHost() + ":" + uri.getPath(); + boolean durable; synchronized (transferClaims) { // The persisted form carries a RECEIPT TIME that the in-memory form does not need. The // stamp is a Lamport sequence, and observeSequence deliberately drags that ahead of 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 1ca6dc9c86a..a34edee0d42 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 @@ -696,6 +696,14 @@ void swiftPackageProductsReachTheWatchTarget(@TempDir Path tmp) throws Exception assertTrue(ruby.contains("watch_import_text"), ruby); assertTrue(ruby.contains("no staged watch source imports it"), "a skip has to say so, or the link error that follows names nothing: " + ruby); + // On module boundaries, not as a substring: a product Foo must not look used by a source + // importing FooBar, which mirrored an unrelated iOS-only package onto the watch. + assertTrue(ruby.contains("Regexp.escape(name)"), ruby); + assertFalse(ruby.contains("watch_import_text.include?(\"import #{name}\")"), + "the raw substring test is what matched a prefix: " + ruby); + // And Swift's declaration-scoped form names the module just as the plain one does; reading + // only `import Foo` dropped a product the source genuinely needs. + assertTrue(ruby.contains("typealias|struct|class|enum|protocol|let|var|func"), ruby); // Both halves are required. Listing the product on the target is what makes Xcode resolve // the package for it; the frameworks-phase build file is what links it. Either alone // produces a project that still fails, and differently. @@ -743,6 +751,26 @@ void vendoredFrameworksDeclaringWatchosAreLinkedEmbeddedAndFound(@TempDir Path t assertTrue(ruby.contains("if vendored_linked"), ruby); } + /** + * Readiness is not published from the bootstrap, because that code cannot run. + * + *

The stub's main reaches Display.init -> postInit -> IOSNative.initVM, whose watch branch + * blocks its thread forever exactly as UIApplicationMain does on the phone. A readiness call + * placed after the stub's main was dead code, so the flag stayed false and every background and + * foreground transition queued for ever -- which is the stop()/start() the watch app was + * missing to begin with. It is published from inside that branch instead.

+ */ + @Test + void theBootstrapDoesNotTryToPublishReadinessAfterMain(@TempDir Path tmp) throws Exception { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + File dir = tmp.toFile(); + parse(req).writeWatchEntry(req, dir); + String boot = read(new File(dir, "CN1WatchBootstrap.m")); + assertFalse(boot.contains("cn1_watch_runtime_markJavaReady"), + "unreachable after the stub's main, which never returns: " + boot); + } + /// The bootstrap has to call the stub that actually exists in the watch binary. @Test void theBootstrapEntersTheWatchStub(@TempDir Path tmp) throws Exception { From 079bece2c27b1f8b09c209d33d109fd60daa3678 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:22:42 +0700 Subject: [PATCH 222/250] Wearables: keep the safe area inside the rounded display, retry a refused clock floor The non-circular skins advertised a safe area with no horizontal inset, on the reasoning that a rounded rectangle keeps its full width. It keeps it along the middle and nowhere near the corners: the display is drawn with a 28px radius, so the advertised rectangle's own corners fell outside it and a component correctly honouring getDisplaySafeArea() could still be clipped by the bezel -- the one failure this metadata exists to prevent, and the one that only shows on hardware. The inset floor is now derived from the radius, r * (1 - 1/sqrt(2)), and the radius is a named constant so the artwork and the metadata cannot drift. A new test walks every skin's safe corners against the shape actually drawn, circle or rounded rectangle. persistClock advanced its in-memory record BEFORE the write, so a refused commit() was remembered as a success: every later call saw the floor as already persisted, returned early, and nothing ever reached disk. The next process then restored the older floor and could publish below a peer item that is still there, which deliverIfOutranks ranks stale and drops silently. The record now moves only on a successful write, which makes the next observation or publication a retry. And the Swift import matcher accepted only @_exported in front of an import. @preconcurrency, @_implementationOnly and @_spi(Name) are all valid there, as are access-level imports, and each of those lines was read as "unused" -- dropping a module the source cannot compile without. Any attribute list is accepted now; fifteen cases checked against the generated regex. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/WatchNativeBuilder.java | 14 +++- .../builders/wearable/CN1WearableBridge.java | 23 ++++-- .../builders/WatchNativeBuilderTest.java | 7 ++ .../javase/WatchSkinCoordinateMapTest.java | 81 +++++++++++++++++++ tools/watch-skins/GenerateWatchSkins.java | 29 ++++++- 5 files changed, 143 insertions(+), 11 deletions(-) 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 81428331081..350f6329d2d 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 @@ -1907,10 +1907,18 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // the watch compiles the phone's sources and needs the phone's packages. .append(" if watch_import_text\n") .append(" q = Regexp.escape(name)\n") - // Swift: `import Foo`, `import Foo.Bar`, and the declaration-scoped - // `import struct Foo.Bar` -- the optional kind keyword is the whole difference. + // Swift: `import Foo`, `import Foo.Bar`, the declaration-scoped + // `import struct Foo.Bar`, and whatever decorates the line in front of it. + // + // ANY attribute, not a hard-coded @_exported: @preconcurrency, + // @_implementationOnly, @_spi(Name) and the rest are all valid there, and a + // pattern naming one of them classified `@preconcurrency import Foo` as unused and + // dropped a module the source cannot compile without. Same for the access-level + // modifiers Swift now allows on an import. // Objective-C / C: `#import `, `#import "Foo/Foo.h"`, `@import Foo;`. - .append(" swift_import = /^\\s*(?:@_exported\\s+)?import\\s+" + .append(" swift_import = /^\\s*(?:@\\w+(?:\\([^)]*\\))?\\s*)*" + + "(?:(?:public|package|internal|fileprivate|private)\\s+)?" + + "import\\s+" + "(?:typealias|struct|class|enum|protocol|let|var|func)?\\s*" + "#{q}(?:\\.|\\s|$)/\n") .append(" objc_import = /(?:@import\\s+#{q}\\s*;|[<\\\"]#{q}\\/)/\n") 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 adcf19a43ba..7ba721c4e2d 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 @@ -1236,7 +1236,11 @@ private static void persistClock(long value) { if (c == null || value <= persistedClock) { return; } - persistedClock = value; + // persistedClock is NOT advanced yet. It records what is on DISK, and moving it before the + // write meant a refused commit was remembered as a success: every later call saw + // value <= persistedClock, returned early, and the floor never reached disk at all. The + // next process then restored the older floor and published a sequence below a peer item + // that is still there, which deliverIfOutranks ranks stale and drops without a callback. try { // commit(), not apply(). This floor is the promise that no future publication of ours // will draw a sequence at or below one we have already SEEN from a peer. apply() writes @@ -1247,11 +1251,20 @@ private static void persistClock(long value) { // // Runs off the main thread (the Data Layer workers), and only when the floor actually // moves, which is rare: the blocking write is bounded and not on any UI path. - c.getSharedPreferences(CLOCK_PREFS, Context.MODE_PRIVATE) - .edit().putLong(CLOCK_KEY, value).commit(); + if (c.getSharedPreferences(CLOCK_PREFS, Context.MODE_PRIVATE) + .edit().putLong(CLOCK_KEY, value).commit()) { + persistedClock = value; + return; + } } catch (Throwable unavailable) { - // Best effort: the in-memory floor still holds for this process. - } + // Falls through to the retry below, which is the same situation: nothing on disk. + } + // A store that is full or momentarily unwritable is the case this exists for, so a single + // refused write must not be the end of it. persistedClock stays where it was, so the next + // observation or publication tries again -- and there is always a next one before the floor + // matters, because the floor only matters when this device publishes. + android.util.Log.w("CN1Wearable", "the wearable logical-clock floor " + value + + " was not written; it will be retried on the next observation"); } /// A context for a cold service process, where the clock still has to be durable. 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 a34edee0d42..439279c488f 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 @@ -704,6 +704,13 @@ void swiftPackageProductsReachTheWatchTarget(@TempDir Path tmp) throws Exception // And Swift's declaration-scoped form names the module just as the plain one does; reading // only `import Foo` dropped a product the source genuinely needs. assertTrue(ruby.contains("typealias|struct|class|enum|protocol|let|var|func"), ruby); + // Any attribute, not one hard-coded name: @preconcurrency, @_implementationOnly and + // @_spi(Name) are all valid in front of an import, and naming only @_exported classified + // those lines as unused and dropped a module the source cannot compile without. Access-level + // imports are the same story. + assertTrue(ruby.contains("(?:@\\w+(?:\\([^)]*\\))?\\s*)*"), + "the attribute prefix has to be general: " + ruby); + assertTrue(ruby.contains("public|package|internal|fileprivate|private"), ruby); // Both halves are required. Listing the product on the target is what makes Xcode resolve // the package for it; the frameworks-phase build file is what links it. Either alone // produces a project that still fails, and differently. diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/WatchSkinCoordinateMapTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/WatchSkinCoordinateMapTest.java index 29c3ea0d0be..b16e644e57a 100644 --- a/maven/javase/src/test/java/com/codename1/impl/javase/WatchSkinCoordinateMapTest.java +++ b/maven/javase/src/test/java/com/codename1/impl/javase/WatchSkinCoordinateMapTest.java @@ -82,6 +82,87 @@ public void everyWatchSkinCarriesCoordinateMapsMatchingItsDeclaredDisplay() thro } } + /** + * The advertised safe area has to lie inside the display that is actually drawn. + * + *

A rounded rectangle loses its corners, and the non-circular skins advertised a safe area + * with no horizontal inset at all -- so its own corners fell outside the drawn display and a + * component that correctly honours {@code getDisplaySafeArea()} could still be clipped by the + * bezel. That is the one bug this metadata exists to prevent, and the one that only shows up on + * hardware.

+ */ + @Test + public void everySafeAreaLiesInsideTheDrawnDisplay() throws Exception { + for (String skinName : SKINS) { + ZipFile zip = new ZipFile(locate(skinName)); + try { + Properties props = new Properties(); + InputStream in = zip.getInputStream(entry(zip, skinName, "skin.properties")); + try { + props.load(in); + } finally { + in.close(); + } + int dw = Integer.parseInt(props.getProperty("displayWidth")); + int dh = Integer.parseInt(props.getProperty("displayHeight")); + int x = Integer.parseInt(props.getProperty("safePortraitX")); + int y = Integer.parseInt(props.getProperty("safePortraitY")); + int w = Integer.parseInt(props.getProperty("safePortraitWidth")); + int h = Integer.parseInt(props.getProperty("safePortraitHeight")); + assertTrue(w > 0 && h > 0, skinName + " has an empty safe area"); + assertTrue(x + w <= dw && y + h <= dh, + skinName + " safe area runs past the display"); + if (Boolean.parseBoolean(props.getProperty("roundScreen"))) { + assertCornersInsideCircle(skinName, dw, dh, x, y, w, h); + } else { + assertCornersInsideRoundedRect(skinName, dw, dh, x, y, w, h); + } + } finally { + zip.close(); + } + } + } + + /** Every corner of the safe rectangle inside the inscribed circle of a round face. */ + private void assertCornersInsideCircle(String skinName, int dw, int dh, + int x, int y, int w, int h) { + double cx = dw / 2.0; + double cy = dh / 2.0; + int[][] corners = {{x, y}, {x + w, y}, {x, y + h}, {x + w, y + h}}; + for (int[] c : corners) { + double u = (c[0] - cx) / cx; + double v = (c[1] - cy) / cy; + assertTrue(u * u + v * v <= 1.0001, + skinName + " safe corner (" + c[0] + "," + c[1] + ") is outside the round face"); + } + } + + /** + * Every corner of the safe rectangle inside the drawn rounded rectangle. + * + *

The display's corner arc is centred at {@code (r, r)}, so a corner inset by {@code dx} and + * {@code dy} is inside when {@code (r-dx)^2 + (r-dy)^2 <= r^2} -- and trivially inside once + * either inset reaches {@code r}.

+ */ + private void assertCornersInsideRoundedRect(String skinName, int dw, int dh, + int x, int y, int w, int h) { + int[][] insets = {{x, y}, {dw - (x + w), y}, {x, dh - (y + h)}, + {dw - (x + w), dh - (y + h)}}; + for (int[] i : insets) { + double dx = i[0]; + double dy = i[1]; + boolean inside = dx >= DISPLAY_CORNER_RADIUS || dy >= DISPLAY_CORNER_RADIUS + || Math.pow(DISPLAY_CORNER_RADIUS - dx, 2) + + Math.pow(DISPLAY_CORNER_RADIUS - dy, 2) + <= DISPLAY_CORNER_RADIUS * DISPLAY_CORNER_RADIUS + 0.01; + assertTrue(inside, skinName + " safe area corner inset (" + i[0] + "," + i[1] + + ") falls outside the rounded display, radius " + DISPLAY_CORNER_RADIUS); + } + } + + /** Mirrors GenerateWatchSkins.DISPLAY_CORNER_RADIUS -- the radius the artwork is drawn with. */ + private static final int DISPLAY_CORNER_RADIUS = 28; + /** * The black region of the map has to be exactly the display the properties declare -- that * rectangle is what the simulator draws the app into, and a map disagreeing with the diff --git a/tools/watch-skins/GenerateWatchSkins.java b/tools/watch-skins/GenerateWatchSkins.java index 5ed1b8f6e75..ef25ec824fa 100644 --- a/tools/watch-skins/GenerateWatchSkins.java +++ b/tools/watch-skins/GenerateWatchSkins.java @@ -47,6 +47,12 @@ * java -cp /tmp/wskin GenerateWatchSkins path/to/iPhoneX.skin outDir Themes/AndroidMaterialTheme.res */ public class GenerateWatchSkins { + /// Corner radius of the drawn display on a non-circular face, in pixels. + /// + /// Named because two things depend on it and they must not drift: the artwork that draws the + /// rounded display, and the safe-area inset that keeps content inside it. + static final int DISPLAY_CORNER_RADIUS = 28; + static class Model { final String file, label; final int dw, dh; // display size in points @@ -151,7 +157,8 @@ static void generate(Model m, byte[] themeRes, File outDir) throws Exception { if (m.circular) { g.fillOval(displayX, displayY, m.dw, m.dh); } else { - g.fill(new RoundRectangle2D.Float(displayX, displayY, m.dw, m.dh, 56, 56)); + g.fill(new RoundRectangle2D.Float(displayX, displayY, m.dw, m.dh, + DISPLAY_CORNER_RADIUS * 2, DISPLAY_CORNER_RADIUS * 2)); } g.dispose(); @@ -196,8 +203,24 @@ static void generate(Model m, byte[] themeRes, File outDir) throws Exception { // as well as its height. Reporting the full display width on a round watch is what lets a // layout that correctly honours the safe area still put content in the clipped left and right // corners. - int inset = Math.round(m.dh * (m.circular ? 0.15f : 0.06f)); - int insetX = m.circular ? Math.round(m.dw * 0.15f) : 0; + // A rounded rectangle loses its CORNERS, so it has to inset horizontally too. + // + // The old answer was zero on the X axis, on the reasoning that a rounded rectangle keeps + // its full width. It keeps it along the middle and nowhere near the top and bottom, and the + // safe area is one rectangle: with insetX = 0 the advertised rectangle's own corners fell + // outside the drawn display, so a component that correctly honours getDisplaySafeArea() + // could still be clipped by the bezel -- which is the one bug this metadata exists to + // prevent, and the one that only shows up on hardware. + // + // The corner arc has its centre at (r, r), so a point inset by d on both axes is inside it + // when (r - d)^2 * 2 <= r^2, i.e. d >= r * (1 - 1/sqrt(2)). Rounded up, and applied as a + // FLOOR rather than a replacement: the vertical inset is already larger than this on every + // shipped size, and shrinking it to the geometric minimum would hand back margin that the + // bezel curve does not actually leave usable. + int cornerInset = m.circular ? 0 + : (int) Math.ceil(DISPLAY_CORNER_RADIUS * (1.0 - 1.0 / Math.sqrt(2.0))); + int inset = Math.max(cornerInset, Math.round(m.dh * (m.circular ? 0.15f : 0.06f))); + int insetX = m.circular ? Math.round(m.dw * 0.15f) : cornerInset; StringBuilder p = new StringBuilder(); p.append("# ").append(m.label).append(" - Codename One simulator skin (placeholder art)\n"); p.append("touch=true\n"); From 892b5306affa9b1bd7d4f5eaf2f4f28088359dc4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:07:55 +0700 Subject: [PATCH 223/250] Wearables: hold spooled deliveries until they land, attribute write-through to its caller The spool removed every record before replaying any of them, so a process reclaimed in that window took the batch with it -- the crash the spool exists to survive. Each record is now claimed rather than deleted: the attempt count is written back first, the callback runs, and only then is the record forgotten. The count is what keeps that bounded, so a listener that throws or a payload that kills the process is retried three times and then abandoned by name, rather than replaying on every launch for ever. Sensor write-through is attributed to the class that asked for it. The flag was app-wide, so a watch lifecycle calling setWriteToStore(true) made the PHONE reach health the moment its own code touched any sensors class -- and entitling the phone against a profile without HealthKit fails release signing. The scanner now reports which class it is reading, and a root reaches health through the sensors package only when it reaches one of the callers. The clock floor is re-derived from what is actually published, in the background at startup. A refused commit leaves nothing on disk and the retry only happens on the next observation, so the first putData of a run could stamp a sequence below a peer item that is still there and have its own update ranked stale. The published items are durable whatever the disk did, and one pass over them puts the clock above all of it -- without refusing to publish, which would trade a possible loss for a certain one. A static archive is judged on its architectures instead of excluded outright. arm64_32 and armv7k exist on watchOS and nowhere else, so either is unambiguous proof of a watch slice -- unlike arm64, which is an iPhone and an Apple silicon watch simulator at once. Skipping every .a left the watch target compiling a caller and failing on its symbols; libPods-*.a stays excluded, since it is generated for the iOS target and never has one. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/codename1/builders/Executor.java | 15 ++ .../com/codename1/builders/IPhoneBuilder.java | 48 ++++-- .../builders/WatchNativeBuilder.java | 34 +++- .../builders/wearable/CN1WearableBridge.java | 153 +++++++++++++++--- .../IPhoneBuilderHealthListenerScopeTest.java | 26 +++ .../builders/WatchNativeBuilderTest.java | 6 + 6 files changed, 242 insertions(+), 40 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 147fab3cc07..b87219f3f91 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -501,6 +501,20 @@ public default void usesClassMethodWithBooleanArgument(String cls, String method, Boolean value) { } + /** + * Names the class whose bytecode the following callbacks come from. + * + *

Every other callback describes what is being CALLED, which answers "does this app do + * X" and nothing about where. That is the same question until a project has two translation + * roots: a flag raised by the watch lifecycle then entitles the phone too, and an + * entitlement the phone's provisioning profile does not carry fails release signing. + * Recording the caller lets a per-root walk decide which target actually did it.

+ * + * @param internalName the caller's internal name, slashes and all + */ + public default void scanningClass(String internalName) { + } + /** * Reports a call together with the descriptor of the method it * resolves to. @@ -613,6 +627,7 @@ protected void scanClassesForPermissions(File directory, final ClassScanner scan public void visit(int i, int accessFlags, String string, String string1, String superName, String[] interfaces) { scannedName = string; scannedSuper = superName; + scanner.scanningClass(string); // ACC_PUBLIC 0x0001, ACC_INTERFACE 0x0200, // ACC_ABSTRACT 0x0400. A class the generated // bindings construct from another package has 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 6c05137ef54..f0feabfbeff 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 @@ -356,19 +356,16 @@ boolean reachesHealth(java.util.Set reachable) { continue; } if (name.startsWith("com/codename1/health/sensors/")) { - // Normally BLE and nothing more -- but a sensor session can be told to write its - // samples through to the store, and then the sensors package IS the route into - // HealthKit. Excluding it unconditionally stripped the entitlement and the listener - // bindings from whichever target actually does the writing, which is the failure - // this attribution exists to prevent, arriving from the other direction. + // Normally BLE and nothing more. The exception is a sensor session told to write + // its samples through to the store, and then the sensors package IS a route into + // HealthKit -- excluding it unconditionally stripped the entitlement from whichever + // target does the writing, the same failure from the other direction. // - // The write-through flag is app-wide, so a root that merely touches the sensors - // package is entitled alongside the one that writes. That is the safe direction: - // over-entitling costs nothing at runtime, under-entitling fails the authorization - // request. - if (sensorWriteThrough) { - return true; - } + // Decided below, from the CALLER of that method rather than from the app-wide flag. + // The flag said only "somebody writes through", so a watch lifecycle switching it on + // made the phone reach health the moment its own code touched any sensors class, + // and entitling the phone against a profile without HealthKit fails release + // signing. continue; } if (!"com/codename1/health/Health".equals(name) @@ -376,6 +373,13 @@ boolean reachesHealth(java.util.Set reachable) { return true; } } + // The sensors package, decided per root: this graph reaches health through it only when it + // reaches a class that actually asked for write-through. + for (String caller : sensorWriteThroughCallers) { + if (reachable.contains(caller)) { + return true; + } + } return false; } @@ -631,6 +635,18 @@ private static boolean healthCapabilityRequested(BuildRequest request, String al /// permission but not the same evidence, and only this one says the sensors package is a route /// into HealthKit for the root that reaches it. boolean sensorWriteThrough; + + /// The classes that actually made that call, by internal name. + /// + /// The flag alone is app-wide, and app-wide is the wrong grain the moment there are two + /// translation roots: a watch lifecycle switching write-through on made the PHONE reach health + /// as soon as its own code touched any sensors class, and entitling the phone for HealthKit + /// against a profile without the capability fails release signing. A root reaches health + /// through the sensors package only when it reaches one of THESE. + final java.util.Set sensorWriteThroughCallers = new java.util.HashSet(); + + /// The class the scanner is currently reading, so the callbacks above can be attributed. + private String scanningClassInternal; private boolean usesCn1Camera; private boolean usesCn1Ar; private boolean usesCn1Vision; @@ -1701,9 +1717,17 @@ public void usesClassMethodWithBooleanArgument(String cls, usesHealthStore = true; usesHealthWrite = true; sensorWriteThrough = true; + if (scanningClassInternal != null) { + sensorWriteThroughCallers.add(scanningClassInternal); + } } } + @Override + public void scanningClass(String internalName) { + scanningClassInternal = internalName; + } + @Override public void usesClassMethod(String cls, String method) { // The catalog first: it decides frameworks and plist 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 350f6329d2d..2123e539d38 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 @@ -1717,6 +1717,25 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // whoever built the binary. Guessing from the architecture list would not work -- arm64 is // both an iPhone and an Apple silicon watch simulator -- and guessing wrong here links an // iOS-only binary into a watch slice, which fails later and less clearly. + // A static archive is judged by the architectures it was actually built for. + // + // arm64_32 and armv7k exist on watchOS and nowhere else, so either one is proof of a watch + // device slice with no ambiguity to resolve -- unlike arm64, which is an iPhone and an + // Apple silicon watch simulator at once. An archive that names neither cannot link into a + // watch device build, and saying so by name beats a link error listing its symbols. + s.append("def cn1_watch_archive_has_watch_slice(ref)\n") + .append(" path = (ref.real_path.to_s rescue nil)\n") + .append(" return false unless path && File.exist?(path)\n") + .append(" archs = `lipo -archs \"#{path}\" 2>/dev/null`.split\n") + // A thin archive answers nothing to lipo -archs; ask the file itself. + .append(" archs = `file \"#{path}\" 2>/dev/null`.scan(" + + "/arm64_32|armv7k/) if archs.empty?\n") + .append(" watch = archs.any? { |a| a == 'arm64_32' || a == 'armv7k' }\n") + .append(" puts \"[watchNative] #{File.basename(path)} #{watch ? 'has' : 'has no'}" + + " watchOS slice (#{archs.empty? ? 'unknown' : archs.join(' ')})\"\n") + .append(" watch\n") + .append("end\n"); + s.append("def cn1_watch_bundle_supports_watchos(ref)\n") .append(" path = (ref.real_path.to_s rescue nil)\n") .append(" return false unless path && File.exist?(path)\n") @@ -1760,12 +1779,16 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // Linkable inputs only. The app target's frameworks phase also carries entries that // are not libraries -- the Info.plist and the prefix header are both in there. // - // .a is deliberately absent. A static library is built, not provided by the SDK: - // CocoaPods' libPods-*.a and any vendored archive are compiled for iOS and have no - // watch slice, so linking one is a guaranteed failure rather than a possible one. + // .a is included, and then judged on what is actually in the archive. + // + // Skipping every static library was wrong for the developer's own: an ios.add_libs + // archive built with a watchOS slice links perfectly well, and omitting it left the + // watch target compiling the caller and failing on its symbols. CocoaPods' + // libPods-*.a is still excluded outright above -- it is generated for the iOS + // target and never has one. .append(" next unless base.end_with?('.framework') " + "|| base.end_with?('.xcframework') || base.end_with?('.dylib') " - + "|| base.end_with?('.tbd')\n") + + "|| base.end_with?('.tbd') || base.end_with?('.a')\n") .append(" if base.end_with?('.framework') || base.end_with?('.xcframework')\n") .append(" if ref.source_tree == 'SDKROOT'\n") .append(" present = !watch_fw_dirs.empty? && watch_fw_dirs.all? { |dirs| " @@ -1776,6 +1799,9 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, .append(" present = cn1_watch_bundle_supports_watchos(ref)\n") .append(" vendored_linked ||= present\n") .append(" end\n") + .append(" elsif base.end_with?('.a')\n") + .append(" present = cn1_watch_archive_has_watch_slice(ref)\n") + .append(" vendored_linked ||= present\n") .append(" else\n") .append(" stem = base.sub(/\\.(dylib|tbd)\\z/, '')\n") .append(" present = !watch_sdks.empty? && watch_sdks.all? { |sdk| " 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 7ba721c4e2d..cd2344650d0 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 @@ -155,6 +155,14 @@ public CN1WearableBridge(Context context) { // event of this run -- a one-shot message and a data removal have no other way back. First, // because these are older than whatever arrives next and the app should see them in order. drainSpool(this.context); + // And re-derive the logical clock from what is actually published. + // + // The stored floor can be missing: a refused commit leaves nothing on disk, and the retry + // only happens on the next observation. Restored from an older floor, the first putData of + // this run could stamp a sequence below a peer item that is still there, and + // deliverIfOutranks then ranks our own update stale and drops it with no error. The + // published items are themselves durable, so reading them says what the disk could not. + reconcileClockFromPublishedItems(); // Sweep at startup as well as after each publish. An app that sends a few files and then // stops would otherwise never run the sweep again, leaving its last transfers published // indefinitely -- the post-publish sweep only helps an app that keeps transferring. @@ -1267,6 +1275,35 @@ private static void persistClock(long value) { + " was not written; it will be retried on the next observation"); } + /// Raises the logical clock to match the highest sequence currently published, in the + /// background. + /// + /// Off the calling thread because it is a Data Layer round trip, and at startup because that is + /// the only moment the in-memory clock can be BEHIND what this device itself published -- every + /// later read raises it through sequenceOf as a matter of course. + private void reconcileClockFromPublishedItems() { + transferTimer.schedule(new java.util.TimerTask() { + public void run() { + try { + DataItemBuffer items = Tasks.await(dataClient.getDataItems(), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + try { + for (DataItem item : items) { + // sequenceOf observes as it reads, which is the whole point: one pass + // over what exists puts the clock above all of it. + sequenceOf(valueMap(item)); + } + } finally { + items.release(); + } + } catch (Throwable unavailable) { + // Best effort. The wall-clock seed still orders the common case, and the next + // item this device reads raises the clock anyway. + } + } + }, 0); + } + /// A context for a cold service process, where the clock still has to be durable. private static volatile Context serviceContext; @@ -1403,7 +1440,11 @@ private static boolean spool(Context context, String kind, String path, byte[] p edit.putLong(SPOOL_SEQ_KEY, seq); // Zero-padded, because the drain replays in key order and the whole point is that // a message arrives in the order it was sent. - edit.putString(spoolKey(seq), kind + "|" + encode(path) + "|" + // + // The leading 0 is the attempt count. Written explicitly rather than left to be + // inferred, so the record has one shape everywhere and no reader has to guess + // whether the first field is a count or a kind. + edit.putString(spoolKey(seq), "0|" + kind + "|" + encode(path) + "|" + (payload == null ? "" : android.util.Base64.encodeToString( payload, android.util.Base64.NO_WRAP))); trimSpool(prefs, edit); @@ -1417,25 +1458,36 @@ private static boolean spool(Context context, String kind, String path, byte[] p } } - /// Replays everything written down, oldest first, and forgets it only once it is taken. + /// Replays everything written down, oldest first, forgetting each only once it is delivered. static void drainSpool(Context context) { - for (String record : takeSpooled(context)) { + for (Map.Entry e : claimSpooled(context).entrySet()) { // Outside the lock. A listener runs arbitrary app code, and holding the spool across // it would block every worker trying to write the next delivery down. - replaySpooled(record); + replaySpooled(e.getValue()); + // And only NOW is the record gone. Removing the whole batch up front was simpler and + // lost data: a process reclaimed between that commit and the callbacks took every + // record with it, which is precisely the crash this spool exists to survive. + releaseSpooled(context, e.getKey()); } } - /// Removes every spooled record and returns it, oldest first. + /// How many launches a single record may be replayed on before it is abandoned. /// - /// Taken BEFORE replay, and committed. A listener that throws must not leave the entry behind - /// to be replayed on every launch from then on: delivered once is the contract for a one-shot, - /// and this is the only place that can hold to it. - private static java.util.List takeSpooled(Context context) { - java.util.List records = new ArrayList(); + /// The cost of keeping a record until delivery is that a listener which throws, or a process + /// that dies mid-callback, sees it again next time. That is the right trade for a one-shot -- + /// delivered twice is recoverable, lost is not -- but it cannot be unbounded, or one poison + /// payload replays on every launch for ever. + private static final int SPOOL_MAX_ATTEMPTS = 3; + + /// Marks every spooled record as being delivered, and returns the ones still worth trying. + /// + /// The attempt count is written back BEFORE the callbacks run, so a record that kills the + /// process is counted against its budget rather than retried for ever. + private static java.util.Map claimSpooled(Context context) { + java.util.Map out = new LinkedHashMap(); Context c = spoolContext(context); if (c == null) { - return records; + return out; } synchronized (SPOOL_LOCK) { try { @@ -1449,29 +1501,82 @@ private static java.util.List takeSpooled(Context context) { } } if (keys.isEmpty()) { - return records; + return out; } java.util.Collections.sort(keys); android.content.SharedPreferences.Editor edit = prefs.edit(); - for (String k : keys) { - edit.remove(k); - } - if (!edit.commit()) { - // Still on disk, so leave it there and try again next time rather than - // replaying records this process may fail to remove afterwards. - return new ArrayList(); - } for (String k : keys) { Object raw = all.get(k); - if (raw instanceof String) { - records.add((String) raw); + if (!(raw instanceof String)) { + edit.remove(k); + continue; + } + String record = (String) raw; + int attempts = attemptsOf(record) + 1; + if (attempts > SPOOL_MAX_ATTEMPTS) { + android.util.Log.w("CN1Wearable", "giving up on a spooled wearable delivery" + + " after " + SPOOL_MAX_ATTEMPTS + " attempts: " + bodyOf(record)); + edit.remove(k); + continue; } + edit.putString(k, attempts + "|" + bodyOf(record)); + out.put(k, bodyOf(record)); + } + if (!edit.commit()) { + // The attempt counts did not land, so replaying now would be unbounded on a + // record that keeps killing the process. Everything stays on disk for the next + // launch, which is the safe direction. + return new LinkedHashMap(); } } catch (Throwable unavailable) { - return new ArrayList(); + return new LinkedHashMap(); } } - return records; + return out; + } + + /// Forgets one record, after its callback has been handed to the application. + private static void releaseSpooled(Context context, String key) { + Context c = spoolContext(context); + if (c == null) { + return; + } + synchronized (SPOOL_LOCK) { + try { + c.getSharedPreferences(SPOOL_PREFS, Context.MODE_PRIVATE) + .edit().remove(key).commit(); + } catch (Throwable unavailable) { + // It keeps its attempt count and is retried, then abandoned. A duplicate is + // recoverable; this is the direction to fail in. + } + } + } + + /// The leading attempt count of a stored record, or 0 for one written before it had one. + private static int attemptsOf(String record) { + int bar = record.indexOf('|'); + if (bar <= 0) { + return 0; + } + try { + return Integer.parseInt(record.substring(0, bar)); + } catch (NumberFormatException notCounted) { + return 0; + } + } + + /// Everything after the attempt count: the record replaySpooled understands. + private static String bodyOf(String record) { + int bar = record.indexOf('|'); + if (bar <= 0) { + return record; + } + try { + Integer.parseInt(record.substring(0, bar)); + } catch (NumberFormatException notCounted) { + return record; + } + return record.substring(bar + 1); } private static void replaySpooled(String record) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java index 7af6962f4b8..3590512cb23 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHealthListenerScopeTest.java @@ -127,15 +127,41 @@ void sensorWriteThroughMakesTheSensorsPackageReachHealth() { "a BLE-only root must not be entitled for HealthKit"); builder.sensorWriteThrough = true; + builder.sensorWriteThroughCallers.add("com/acme/MyApp"); assertTrue(builder.reachesHealth(sensorsOnly), "but writing samples through to the store is HealthKit use"); } + /** + * Write-through by the OTHER root does not entitle this one. + * + *

The app-wide flag said only "somebody writes through", so a watch lifecycle switching it + * on made the phone reach health as soon as its own code touched any sensors class -- and + * entitling the phone against a provisioning profile without HealthKit fails release signing. + * The caller decides.

+ */ + @Test + void writeThroughByAnotherRootDoesNotEntitleThisOne() { + IPhoneBuilder builder = new IPhoneBuilder(); + builder.sensorWriteThrough = true; + builder.sensorWriteThroughCallers.add("com/acme/WatchLifecycle"); + + Set phone = reachable("com/acme/MyApp", + "com/codename1/health/sensors/SensorSession"); + assertFalse(builder.reachesHealth(phone), + "the phone reaches the sensors package but not the class that writes through"); + + Set watch = reachable("com/acme/WatchApp", "com/acme/WatchLifecycle", + "com/codename1/health/sensors/SensorSession"); + assertTrue(builder.reachesHealth(watch), "and the root that does write is entitled"); + } + /** Write-through elsewhere in the app does not make an unrelated root reach health. */ @Test void writeThroughDoesNotEntitleARootThatTouchesNoSensor() { IPhoneBuilder builder = new IPhoneBuilder(); builder.sensorWriteThrough = true; + builder.sensorWriteThroughCallers.add("com/acme/Writer"); assertFalse(builder.reachesHealth(reachable("com/acme/WatchApp", "com/acme/Ui"))); } 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 439279c488f..c935af20840 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 @@ -756,6 +756,12 @@ void vendoredFrameworksDeclaringWatchosAreLinkedEmbeddedAndFound(@TempDir Path t assertTrue(ruby.contains("app_target.copy_files_build_phases"), ruby); // And a project with no vendored framework must produce the project it did before. assertTrue(ruby.contains("if vendored_linked"), ruby); + // A developer's own static archive is judged on what is in it, not excluded outright: + // arm64_32 and armv7k exist on watchOS and nowhere else, so either is unambiguous proof. + // Skipping every .a left the watch compiling the caller and failing on its symbols. + assertTrue(ruby.contains("cn1_watch_archive_has_watch_slice(ref)"), ruby); + assertTrue(ruby.contains("arm64_32") && ruby.contains("armv7k"), ruby); + assertTrue(ruby.contains("base.end_with?('.a')"), ruby); } /** From d0c8bb6b0db06db507e7e92bbad2311ca79562bb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 06:36:42 +0700 Subject: [PATCH 224/250] Wearables: release a spooled record only once a listener has seen it replaySpooled only QUEUES onto WearableConnection -- the listeners run later on the EDT, and not at all while the delivery is parked for want of one. Forgetting the record at queue time therefore lost exactly what the spool exists to survive: a process that dies between the queueing and the callback. WearableConnection gained a delivery-confirmation callback for the two paths that need it, run once every listener has been offered the payload and never while it is parked, and the bridge releases its record from there. Attempts are charged one record at a time. Incrementing the whole batch up front meant the oldest record crashing the process three times threw away every later message and removal with it -- none of which had been tried even once. The phone stub's native registrations are filtered by the phone root, as the watch stub's already were by its own. With two translations the app-wide list roots every native implementation in both, so a watch-only NativeInterface whose Objective-C imports WatchKit broke the phone target: the mirror image of the failure the watch filter fixes. And an injected plist key is matched on its resolved CONTENT rather than as serialized markup. An XML parser reads as that key, which is what the phone's plist does -- so the phone suppressed its default while the watch failed to find it and fell back, and the pair shipped with different marketing versions, which archive validation rejects. Co-Authored-By: Claude Opus 5 (1M context) --- .../wearable/WearableConnection.java | 57 +++++++++ .../com/codename1/builders/IPhoneBuilder.java | 11 +- .../builders/WatchNativeBuilder.java | 56 ++++++--- .../builders/wearable/CN1WearableBridge.java | 116 ++++++++++-------- .../builders/WatchNativeBuilderTest.java | 26 ++++ 5 files changed, 201 insertions(+), 65 deletions(-) diff --git a/CodenameOne/src/com/codename1/wearable/WearableConnection.java b/CodenameOne/src/com/codename1/wearable/WearableConnection.java index 0268b16a775..1e3e2029357 100644 --- a/CodenameOne/src/com/codename1/wearable/WearableConnection.java +++ b/CodenameOne/src/com/codename1/wearable/WearableConnection.java @@ -506,6 +506,27 @@ public static void removeStateListener(WearableStateListener l) { /// - `payload`: the encoded payload /// - `replyToken`: a positive token when the peer is waiting for an answer, otherwise 0 public static void deliverMessage(final String path, final byte[] payload, final int replyToken) { + deliverMessage(path, payload, replyToken, null); + } + + /// The same, with a callback for a port that has the message written down somewhere durable. + /// + /// `delivered` runs on the EDT once every registered listener has been offered the message -- + /// not when it is queued. A port whose spool survives process death needs exactly that + /// distinction: releasing its record when the delivery was merely queued loses the message if + /// the process dies before the EDT gets to it, which is the failure the spool exists for. + /// + /// Not called at all while the delivery is parked for want of a listener. That is the point: the + /// record stays durable until something actually receives it. + /// + /// #### Parameters + /// + /// - `path`: the path the message arrived on + /// - `payload`: the encoded payload + /// - `replyToken`: a positive token when the peer is waiting for an answer, otherwise 0 + /// - `delivered`: run after the listeners have seen it, or null + public static void deliverMessage(final String path, final byte[] payload, + final int replyToken, final Runnable delivered) { deliver(new Runnable() { @Override public void run() { @@ -542,6 +563,11 @@ public void run() { reply == null ? new byte[0] : reply.toByteArray()); } } + // Before the rethrow, and deliberately: every listener HAS been offered the + // message by here, so as far as the port's durable record is concerned this one is + // delivered. Holding it back because a listener threw would replay it on the next + // launch into the same listener, for ever. + runDeliveredCallback(delivered); // Reported, not swallowed -- but only once every listener has been offered the // message and the sender has its answer. if (failure != null) { @@ -683,6 +709,19 @@ public void run() { /// /// - `path`: the path whose value is gone public static void deliverDataRemoved(final String path) { + deliverDataRemoved(path, null); + } + + /// The same, with a callback for a port holding the removal in a durable spool. + /// + /// See [#deliverMessage(String,byte[],int,Runnable)]: `delivered` runs once the listeners have + /// been offered the removal, and never while it is parked for want of one. + /// + /// #### Parameters + /// + /// - `path`: the path whose value was removed + /// - `delivered`: run after the listeners have seen it, or null + public static void deliverDataRemoved(final String path, final Runnable delivered) { deliverTagged(path, new Runnable() { @Override public void run() { @@ -707,6 +746,9 @@ public void run() { } } } + // Every listener has been offered the removal, so the port may forget its durable + // record. Before the rethrow, for the same reason as the message path. + runDeliveredCallback(delivered); // Reported, not swallowed -- but only once every listener has been offered it. if (failure != null) { throw failure; @@ -748,6 +790,21 @@ public void run() { /// Parks a replicated delivery TAGGED with its path, so the cap can prefer an entry the /// incoming one actually supersedes. + /// Runs a port's delivery-confirmation callback without letting it break the dispatch. + /// + /// A port releasing a durable record does I/O, and an exception from that must not be mistaken + /// for a listener failure -- the listeners have already run by the time this is called. + private static void runDeliveredCallback(Runnable delivered) { + if (delivered == null) { + return; + } + try { + delivered.run(); + } catch (RuntimeException portFailed) { + com.codename1.io.Log.e(portFailed); + } + } + private static void deliverTagged(String path, Runnable delivery) { deliverTagged(path, delivery, false); } 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 f0feabfbeff..9365f657c72 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 @@ -2650,7 +2650,16 @@ public void usesClassMethod(String cls, String method) { + " }\n\n" + " public static void main(String[] argv) {\n" + " if(!(argv != null && argv.length > 0 && argv[0].equals(\"ignoreNative\"))) {\n" - + registerNativeImplementationsAndCreateStubs(new URLClassLoader(new URL[]{codenameOneJar.toURI().toURL()}), stubSource, classesDir) + // Filtered by the PHONE root for the same reason the watch stub is filtered by + // its own: with two translations the app-wide list roots every native + // implementation in both, so a watch-only NativeInterface whose Objective-C + // imports WatchKit broke the phone target -- the mirror image of the failure + // the watch filter fixes. + + nativeRegistrationsReachableFrom( + registerNativeImplementationsAndCreateStubs( + new URLClassLoader(new URL[]{codenameOneJar.toURI().toURL()}), + stubSource, classesDir), + phoneReachableClasses) + " }\n" + " " + request.getMainClass() + "Stub stub = new " + request.getMainClass() + "Stub();\n" + " com.codename1.impl.ios.IOSImplementation.setMainClass(stub.i);\n" 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 2123e539d38..64f7de3c886 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 @@ -736,11 +736,12 @@ static java.util.List injectedPlistKeys(BuildRequest request) { if (open < 0) { return out; } - int close = inject.indexOf("", open); + int close = closeOfElement(inject, open + "".length(), ""); if (close < 0) { return out; } - String key = inject.substring(open + "".length(), close).trim(); + String key = plistStringContent( + inject.substring(open + "".length(), close)).trim(); if (key.length() > 0 && !out.contains(key)) { out.add(key); } @@ -753,19 +754,39 @@ static String injectedPlistString(BuildRequest request, String key) { if (inject == null) { return null; } - int at = inject.indexOf("" + key + ""); - if (at < 0) { - return null; - } - int open = inject.indexOf("", at); - if (open < 0) { - return null; - } - int close = closeOfString(inject, open + "".length()); - if (close < 0) { - return null; + // The key's CONTENT, resolved, rather than the literal text `NAME`. + // + // A plist may spell a key as , or wrap a + // comment around part of it, and an XML parser reads all of those as the same key -- which + // is what the phone's plist does. Matching the serialized form here meant the phone + // suppressed its default for a key the watch then failed to find, and the pair shipped with + // different marketing versions, which archive validation rejects. + int at = 0; + while (true) { + int open = inject.indexOf("", at); + if (open < 0) { + return null; + } + int close = closeOfElement(inject, open + "".length(), ""); + if (close < 0) { + return null; + } + at = close + "".length(); + if (!key.equals(plistStringContent( + inject.substring(open + "".length(), close)).trim())) { + continue; + } + int valueOpen = inject.indexOf("", at); + if (valueOpen < 0) { + return null; + } + int valueClose = closeOfString(inject, valueOpen + "".length()); + if (valueClose < 0) { + return null; + } + return plistStringContent( + inject.substring(valueOpen + "".length(), valueClose)); } - return plistStringContent(inject.substring(open + "".length(), close)); } /// The {@code } that closes the element, skipping over CDATA sections. @@ -774,9 +795,14 @@ static String injectedPlistString(BuildRequest request, String key) { /// {@code b]]>} that occurrence is DATA -- the element would be cut in half /// at a point the XML parser reading the phone's plist never stops at. private static int closeOfString(String inject, int from) { + return closeOfElement(inject, from, ""); + } + + /// The end tag that closes an element, skipping over CDATA sections and comments. + private static int closeOfElement(String inject, int from, String closeTag) { int i = from; while (i <= inject.length()) { - int close = inject.indexOf("", i); + int close = inject.indexOf(closeTag, i); if (close < 0) { return -1; } 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 cd2344650d0..2b0aa2c6ccf 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 @@ -1460,14 +1460,25 @@ private static boolean spool(Context context, String kind, String path, byte[] p /// Replays everything written down, oldest first, forgetting each only once it is delivered. static void drainSpool(Context context) { - for (Map.Entry e : claimSpooled(context).entrySet()) { - // Outside the lock. A listener runs arbitrary app code, and holding the spool across - // it would block every worker trying to write the next delivery down. - replaySpooled(e.getValue()); - // And only NOW is the record gone. Removing the whole batch up front was simpler and - // lost data: a process reclaimed between that commit and the callbacks took every - // record with it, which is precisely the crash this spool exists to survive. - releaseSpooled(context, e.getKey()); + for (String key : spooledKeys(context)) { + // ONE record at a time. Charging an attempt to the whole batch up front meant the + // oldest record crashing the process three times threw away every later message and + // removal with it -- none of which had been tried even once. + String record = claimSpooled(context, key); + if (record == null) { + continue; + } + final Context c = context; + final String claimed = key; + // Released from the delivery callback, not here. replaySpooled only QUEUES onto + // WearableConnection: the listeners run later on the EDT, and may not run at all yet if + // none is registered. Forgetting the record at queue time lost exactly what this spool + // exists to survive -- a process that dies between the queueing and the callback. + replaySpooled(record, new Runnable() { + public void run() { + releaseSpooled(c, claimed); + } + }); } } @@ -1479,63 +1490,70 @@ static void drainSpool(Context context) { /// payload replays on every launch for ever. private static final int SPOOL_MAX_ATTEMPTS = 3; - /// Marks every spooled record as being delivered, and returns the ones still worth trying. - /// - /// The attempt count is written back BEFORE the callbacks run, so a record that kills the - /// process is counted against its budget rather than retried for ever. - private static java.util.Map claimSpooled(Context context) { - java.util.Map out = new LinkedHashMap(); + /// The spooled keys in delivery order, without touching their attempt counts. + private static java.util.List spooledKeys(Context context) { + java.util.List keys = new ArrayList(); Context c = spoolContext(context); if (c == null) { - return out; + return keys; } synchronized (SPOOL_LOCK) { try { - android.content.SharedPreferences prefs = - c.getSharedPreferences(SPOOL_PREFS, Context.MODE_PRIVATE); - java.util.Map all = prefs.getAll(); - java.util.List keys = new ArrayList(); - for (String k : all.keySet()) { + for (String k : c.getSharedPreferences(SPOOL_PREFS, Context.MODE_PRIVATE) + .getAll().keySet()) { if (!SPOOL_SEQ_KEY.equals(k)) { keys.add(k); } } - if (keys.isEmpty()) { - return out; + } catch (Throwable unavailable) { + return new ArrayList(); + } + } + // Zero-padded keys, so lexical order IS the order they were written in. + java.util.Collections.sort(keys); + return keys; + } + + /// Charges one attempt to a single record and returns what to replay, or null to skip it. + /// + /// The attempt is written BEFORE the callback runs, so a record that kills the process is + /// counted against its budget rather than retried for ever. + private static String claimSpooled(Context context, String key) { + Context c = spoolContext(context); + if (c == null) { + return null; + } + synchronized (SPOOL_LOCK) { + try { + android.content.SharedPreferences prefs = + c.getSharedPreferences(SPOOL_PREFS, Context.MODE_PRIVATE); + String record = prefs.getString(key, null); + if (record == null) { + return null; } - java.util.Collections.sort(keys); + int attempts = attemptsOf(record) + 1; android.content.SharedPreferences.Editor edit = prefs.edit(); - for (String k : keys) { - Object raw = all.get(k); - if (!(raw instanceof String)) { - edit.remove(k); - continue; - } - String record = (String) raw; - int attempts = attemptsOf(record) + 1; - if (attempts > SPOOL_MAX_ATTEMPTS) { - android.util.Log.w("CN1Wearable", "giving up on a spooled wearable delivery" - + " after " + SPOOL_MAX_ATTEMPTS + " attempts: " + bodyOf(record)); - edit.remove(k); - continue; - } - edit.putString(k, attempts + "|" + bodyOf(record)); - out.put(k, bodyOf(record)); + if (attempts > SPOOL_MAX_ATTEMPTS) { + android.util.Log.w("CN1Wearable", "giving up on a spooled wearable delivery" + + " after " + SPOOL_MAX_ATTEMPTS + " attempts: " + bodyOf(record)); + edit.remove(key); + edit.commit(); + return null; } + edit.putString(key, attempts + "|" + bodyOf(record)); if (!edit.commit()) { - // The attempt counts did not land, so replaying now would be unbounded on a - // record that keeps killing the process. Everything stays on disk for the next - // launch, which is the safe direction. - return new LinkedHashMap(); + // The attempt did not land, so replaying now would be unbounded on a record + // that keeps killing the process. It waits for the next launch. + return null; } + return bodyOf(record); } catch (Throwable unavailable) { - return new LinkedHashMap(); + return null; } } - return out; } - /// Forgets one record, after its callback has been handed to the application. + /// Forgets one record, once its listeners have actually seen it. private static void releaseSpooled(Context context, String key) { Context c = spoolContext(context); if (c == null) { @@ -1579,7 +1597,7 @@ private static String bodyOf(String record) { return record.substring(bar + 1); } - private static void replaySpooled(String record) { + private static void replaySpooled(String record, Runnable delivered) { int first = record.indexOf('|'); int second = first < 0 ? -1 : record.indexOf('|', first + 1); if (first < 0 || second < 0) { @@ -1590,12 +1608,12 @@ private static void replaySpooled(String record) { String encoded = record.substring(second + 1); try { if (SPOOL_REMOVAL.equals(kind)) { - WearableConnection.deliverDataRemoved(path); + WearableConnection.deliverDataRemoved(path, delivered); return; } byte[] payload = encoded.length() == 0 ? new byte[0] : android.util.Base64.decode(encoded, android.util.Base64.NO_WRAP); - WearableConnection.deliverMessage(path, payload, 0); + WearableConnection.deliverMessage(path, payload, 0, delivered); } catch (Throwable unreadable) { // A record this build cannot parse is dropped rather than retried forever. } 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 c935af20840..d8a6668c30b 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 @@ -784,6 +784,32 @@ void theBootstrapDoesNotTryToPublishReadinessAfterMain(@TempDir Path tmp) throws "unreachable after the stub's main, which never returns: " + boot); } + /** + * A key spelled with CDATA is the same key. + * + *

The phone's plist is read by an XML parser, so + * {@code } suppresses its default -- while a + * literal text match here found nothing and gave the watch its fallback version. The pair then + * shipped with different marketing versions, which archive validation rejects.

+ */ + @Test + void injectedKeysAreMatchedAsContentNotAsMarkup(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.plistInject", + "2.0"); + + assertEquals("2.0", + WatchNativeBuilder.injectedPlistString(req, "CFBundleShortVersionString"), + "a CDATA-spelled key names the same key an XML parser sees"); + assertTrue(WatchNativeBuilder.injectedPlistKeys(req).contains("CFBundleShortVersionString"), + "and the key scan has to agree with the lookup"); + + String plist = writeInfoPlist(req, tmp); + assertTrue(plist.contains("CFBundleShortVersionString\n 2.0"), + "the watch carries the injected version, not its fallback: " + plist); + } + /// The bootstrap has to call the stub that actually exists in the watch binary. @Test void theBootstrapEntersTheWatchStub(@TempDir Path tmp) throws Exception { From 2b26070f81ccc6fb4b81eae3abba68185a315ff1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:29:02 +0700 Subject: [PATCH 225/250] Wearables: a spooled record in flight is not claimed again The drain runs from deliverableNow, which every incoming message and removal calls, so a second event re-entered it while the first record was still queued on the EDT. Nothing marked the record as being delivered, so it was claimed a second time: the same callback queued twice, and three such events inside one process burned the whole attempt budget and deleted the durable copy before any listener had run -- turning the protection into the loss it was meant to prevent. Claimed keys are tracked in memory under the same lock that guards the store. A record parked for want of a listener stays claimed for the life of the process, which is right: the parked runnable still holds it, and re-claiming would duplicate the delivery rather than rescue it. A release whose write fails also stays claimed -- the listeners have already had it, so a retry here could only duplicate it, and the next launch is bounded by the attempt count. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/wearable/CN1WearableBridge.java | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) 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 2b0aa2c6ccf..bcc1eff7599 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 @@ -1490,6 +1490,19 @@ public void run() { /// payload replays on every launch for ever. private static final int SPOOL_MAX_ATTEMPTS = 3; + /// Keys that have been claimed and whose delivery has not been confirmed yet. + /// + /// A drain runs from deliverableNow, which every incoming message and removal calls, so a + /// second event can re-enter it while the first record is still queued on the EDT. With nothing + /// marking the record as in flight it was claimed again: the same callback queued twice, and + /// three such events inside one process burned the whole attempt budget and deleted the durable + /// copy before any listener had run. + /// + /// Guarded by SPOOL_LOCK. A record parked for want of a listener stays here for the life of the + /// process, which is exactly right -- the parked runnable still holds it, and re-claiming it + /// would duplicate the delivery rather than rescue it. + private static final java.util.Set SPOOL_IN_FLIGHT = new java.util.HashSet(); + /// The spooled keys in delivery order, without touching their attempt counts. private static java.util.List spooledKeys(Context context) { java.util.List keys = new ArrayList(); @@ -1524,11 +1537,17 @@ private static String claimSpooled(Context context, String key) { return null; } synchronized (SPOOL_LOCK) { + // Already being delivered, by an earlier drain whose callback has not fired. Claiming + // it again would queue the same payload a second time. + if (!SPOOL_IN_FLIGHT.add(key)) { + return null; + } try { android.content.SharedPreferences prefs = c.getSharedPreferences(SPOOL_PREFS, Context.MODE_PRIVATE); String record = prefs.getString(key, null); if (record == null) { + SPOOL_IN_FLIGHT.remove(key); return null; } int attempts = attemptsOf(record) + 1; @@ -1538,16 +1557,19 @@ private static String claimSpooled(Context context, String key) { + " after " + SPOOL_MAX_ATTEMPTS + " attempts: " + bodyOf(record)); edit.remove(key); edit.commit(); + SPOOL_IN_FLIGHT.remove(key); return null; } edit.putString(key, attempts + "|" + bodyOf(record)); if (!edit.commit()) { // The attempt did not land, so replaying now would be unbounded on a record // that keeps killing the process. It waits for the next launch. + SPOOL_IN_FLIGHT.remove(key); return null; } return bodyOf(record); } catch (Throwable unavailable) { + SPOOL_IN_FLIGHT.remove(key); return null; } } @@ -1561,11 +1583,15 @@ private static void releaseSpooled(Context context, String key) { } synchronized (SPOOL_LOCK) { try { - c.getSharedPreferences(SPOOL_PREFS, Context.MODE_PRIVATE) - .edit().remove(key).commit(); + if (c.getSharedPreferences(SPOOL_PREFS, Context.MODE_PRIVATE) + .edit().remove(key).commit()) { + SPOOL_IN_FLIGHT.remove(key); + } + // Still on disk if that failed, and deliberately still in flight: the listeners + // have already had it, so re-claiming it in THIS process would only duplicate the + // delivery. The next launch retries it, and the attempt count bounds that. } catch (Throwable unavailable) { - // It keeps its attempt count and is retried, then abandoned. A duplicate is - // recoverable; this is the direction to fail in. + // Same reasoning. It keeps its attempt count and is retried on a later launch. } } } From 2d35ae605b037762c625d5b1fe5ade0a9838e35d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:40:31 +0700 Subject: [PATCH 226/250] Wearables: one spool drain at a time, and a package gate that knows when it cannot decide Two Data Layer workers could both walk the spool: the first claims the oldest key and is descheduled, the second skips that in-flight key and queues the NEXT record, and the oldest then arrives after it. Sorted keys buy nothing if two threads walk the list at once. A single drain owner now holds the walk, and a second thread returns rather than blocking -- it has a live event to deliver, and the drain already running loops until the store is empty, so a record written meanwhile is still picked up. A Swift package product name is not always its module name: package FooKit can export module Foo, and staged code saying `import Foo` matched no product, so the gate concluded FooKit was unused and the watch failed to compile against a module it does import. The mapping lives in Package.swift, which xcodebuild does not resolve until long after this runs -- but whether the assumption HOLDS can be established here: every module the watch sources import is either a product name or a watchOS SDK framework. When that is true the gate is exact and stays on. When some import is attributable to neither, product and module names demonstrably differ, the gate cannot decide, and it steps aside and says so. A package that then fails on watchOS is named by Xcode; a module silently withheld is not. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/WatchNativeBuilder.java | 42 ++++++++++++++++++- .../builders/wearable/CN1WearableBridge.java | 37 ++++++++++++++++ .../builders/WatchNativeBuilderTest.java | 8 ++++ 3 files changed, 86 insertions(+), 1 deletion(-) 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 64f7de3c886..1ab64f4b298 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 @@ -1952,12 +1952,52 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, .append(" end\n") .append(" end\n") .append("end\n") + // A product name is not always its module name. + // + // A package may export product FooKit containing target Foo, and staged code then + // says `import Foo` -- which matches no product, so the strict gate concluded FooKit + // was unused and the watch failed to compile against a module it does import. The + // mapping lives in the package's own Package.swift, which xcodebuild does not + // resolve until long after this runs, so it cannot be looked up here. + // + // What CAN be established is whether the assumption holds for this project: every + // module the watch sources import is either a product name or a framework in the + // watchOS SDK. When that is true the gate is exact and stays on. When some import + // is attributable to neither, product and module names demonstrably differ here, the + // gate cannot decide, and it steps aside -- mirroring everything, and saying so. + // A package that then fails on watchOS is named by Xcode; a module silently withheld + // is not. + .append("watch_modules = []\n") + .append("if watch_import_text\n") + .append(" watch_import_text.scan(/^\\s*(?:@\\w+(?:\\([^)]*\\))?\\s*)*" + + "(?:(?:public|package|internal|fileprivate|private)\\s+)?import\\s+" + + "(?:typealias|struct|class|enum|protocol|let|var|func)?\\s*" + + "([A-Za-z_]\\w*)/) { |m| watch_modules << m[0] }\n") + .append(" watch_import_text.scan(/@import\\s+([A-Za-z_]\\w*)\\s*;/) " + + "{ |m| watch_modules << m[0] }\n") + .append(" watch_import_text.scan(/[<\\\"]([A-Za-z_]\\w*)\\//) " + + "{ |m| watch_modules << m[0] }\n") + .append(" watch_modules.uniq!\n") + .append("end\n") + .append("product_names = app_target.package_product_dependencies.to_a" + + ".map { |d| d.respond_to?(:product_name) ? d.product_name : nil }" + + ".compact\n") + .append("unattributed = watch_modules.reject { |m| product_names.include?(m) || " + + "watch_fw_dirs.any? { |dirs| dirs.any? { |dd| " + + "File.directory?(File.join(dd, m + '.framework')) } } }\n") + .append("strict_products = watch_import_text && unattributed.empty?\n") + .append("if watch_import_text && !unattributed.empty?\n") + .append(" puts \"[watchNative] linking every Swift package product into the watch " + + "target: #{unattributed.join(' ')} imported by the staged watch sources " + + "matches no product name, so a product's module name differs from it and " + + "the per-product check cannot decide\"\n") + .append("end\n") .append("app_target.package_product_dependencies.to_a.each do |dep|\n") .append(" name = dep.respond_to?(:product_name) ? dep.product_name : nil\n") .append(" next unless name\n") // watch_import_text is nil when the watch shares the phone's translation, and then // the watch compiles the phone's sources and needs the phone's packages. - .append(" if watch_import_text\n") + .append(" if strict_products\n") .append(" q = Regexp.escape(name)\n") // Swift: `import Foo`, `import Foo.Bar`, the declaration-scoped // `import struct Foo.Bar`, and whatever decorates the line in front of it. 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 bcc1eff7599..83c3c807164 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 @@ -1458,8 +1458,43 @@ private static boolean spool(Context context, String kind, String path, byte[] p } } + /// Whether a drain is already walking the store. + /// + /// ONE drain at a time, or the order the app sees is not the order things happened. Two Data + /// Layer workers can both reach here: the first claims the oldest key and is descheduled, the + /// second skips that in-flight key and queues the NEXT record, and the oldest then arrives + /// after it. Sorted keys buy nothing if two threads walk the list at once. + /// + /// A second thread returns rather than blocking. It is a Data Layer worker with a live event to + /// deliver, and the drain it would have run is the one already in progress -- which loops until + /// the store is empty, so anything written meanwhile is picked up before it finishes. + private static boolean spoolDraining; + /// Replays everything written down, oldest first, forgetting each only once it is delivered. static void drainSpool(Context context) { + synchronized (SPOOL_LOCK) { + if (spoolDraining) { + return; + } + spoolDraining = true; + } + try { + while (drainSpoolPass(context)) { + // Again, because a worker that wrote a record while this pass was running returned + // without draining it -- this owner is the one that has to pick it up. + continue; + } + } finally { + synchronized (SPOOL_LOCK) { + spoolDraining = false; + } + } + } + + /// One walk of the store. Returns whether anything was replayed, so the owner knows to look + /// again. + private static boolean drainSpoolPass(Context context) { + boolean replayed = false; for (String key : spooledKeys(context)) { // ONE record at a time. Charging an attempt to the whole batch up front meant the // oldest record crashing the process three times threw away every later message and @@ -1479,7 +1514,9 @@ public void run() { releaseSpooled(c, claimed); } }); + replayed = true; } + return replayed; } /// How many launches a single record may be replayed on before it is abandoned. 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 d8a6668c30b..1bcab140aa9 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 @@ -711,6 +711,14 @@ void swiftPackageProductsReachTheWatchTarget(@TempDir Path tmp) throws Exception assertTrue(ruby.contains("(?:@\\w+(?:\\([^)]*\\))?\\s*)*"), "the attribute prefix has to be general: " + ruby); assertTrue(ruby.contains("public|package|internal|fileprivate|private"), ruby); + // A product name is not always its module name -- package FooKit can export module Foo. + // The gate can tell when that is the case here (an import attributable to no product and no + // watchOS framework) and steps aside rather than withholding a module the sources import. + assertTrue(ruby.contains("strict_products"), ruby); + assertTrue(ruby.contains("unattributed"), ruby); + assertTrue(ruby.contains("matches no product name"), + "stepping aside has to be logged, or a mirrored iOS-only package looks arbitrary: " + + ruby); // Both halves are required. Listing the product on the target is what makes Xcode resolve // the package for it; the frameworks-phase build file is what links it. Either alone // produces a project that still fails, and differently. From e10482cfb07b3514d1c42d42eaf4e3892ad8ea5d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:17:33 +0700 Subject: [PATCH 227/250] Wearables: a reload keeps its parked messages, and a live event does not overtake the drain resetForReload cleared pendingMessages outright while the data queue beside it carefully hands its undelivered paths back. A parked message is one nothing has received yet and there is nothing to receive it from twice: the port consumed the socket frame to park it, and no later enumeration reconstructs a live message the way a replicated value is re-read. Clearing it lost the payload, and a reply-bearing request left the peer waiting out its whole timeout. The queue survives now -- safe because a parked delivery captures the payload and resolves its listeners when it runs, which is the reloaded app's set, and addMessageListener drains it. The single drain owner still let a live event overtake it. A second worker returned from drainSpool immediately, deliverableNow answered true, and it handed the app a new event before the owner had queued an older spooled one -- the reversal the owner was meant to end, arriving through the other door. A live event is spooled instead while a drain is running or any claimed record is unconfirmed, so it takes a later key than everything outstanding and the owner replays it in order. Co-Authored-By: Claude Opus 5 (1M context) --- .../wearable/WearableConnection.java | 15 ++++++++++++- .../builders/wearable/CN1WearableBridge.java | 22 ++++++++++++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/wearable/WearableConnection.java b/CodenameOne/src/com/codename1/wearable/WearableConnection.java index 1e3e2029357..3df9d3b59c1 100644 --- a/CodenameOne/src/com/codename1/wearable/WearableConnection.java +++ b/CodenameOne/src/com/codename1/wearable/WearableConnection.java @@ -1213,7 +1213,20 @@ public static void resetForReload() { } synchronized (pendingMessages) { messageListeners.clear(); - pendingMessages.clear(); + // The QUEUE survives the reload, unlike the listeners. + // + // A parked message is one nothing has received yet, and there is nothing to receive it + // from a second time: the port consumed the socket frame to park it, and no later + // enumeration reconstructs a live message the way a replicated value is re-read above. + // Clearing it therefore lost the payload outright, and a reply-bearing request left the + // peer waiting out its whole timeout for an answer no code would ever be asked for. + // + // Safe to keep, because a parked delivery captures the payload and NOT the listeners: + // it resolves them through messageListenerSnapshot() when it finally runs, which is the + // reloaded app's set. That is the same reason the replicated paths are handed back + // rather than dropped. + // + // Nothing else needs resetting here: the pending list is its own drain state. } synchronized (stateListeners) { stateListeners.clear(); 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 83c3c807164..0925ce07811 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 @@ -1411,7 +1411,7 @@ static void spoolOrDeliverRemoval(Context context, String path) { } } - /// Whether this process can actually run app code, rather than merely hold it in a queue. + /// Whether this process can run app code AND nothing older is still waiting to be replayed. private static boolean deliverableNow(Context context) { try { if (!com.codename1.ui.Display.isInitialized()) { @@ -1421,9 +1421,25 @@ private static boolean deliverableNow(Context context) { return false; } // Initialized, so anything already spooled has to go FIRST -- otherwise a delivery written - // moments ago would arrive after one that happened later. + // moments ago would arrive after one that happened later. Returns at once if another worker + // already owns the drain. drainSpool(context); - return true; + // And a live event does NOT overtake that drain. Answering true while an owner was midway + // through the store let the message worker hand the app a NEW event before the data worker + // had queued an older spooled one -- the reversal the single owner was supposed to end, + // arriving through the other door. Saying no here spools this event instead, so it takes a + // later key than everything outstanding and the owner replays it in order. + return !spoolBusy(); + } + + /// Whether a drain is running or any claimed record has yet to be confirmed. + /// + /// In-flight records count: one parked for want of a listener has not reached the app, and a + /// live event delivered past it would arrive first. + private static boolean spoolBusy() { + synchronized (SPOOL_LOCK) { + return spoolDraining || !SPOOL_IN_FLIGHT.isEmpty(); + } } private static boolean spool(Context context, String kind, String path, byte[] payload) { From bb358b41fec690c499227bcbaa35dab7aa3fa94b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:56:49 +0700 Subject: [PATCH 228/250] Wearables: a cold-start request is not dropped, and a commented plist key is not a key A reply-bearing request arriving at a stopped app went into the process-local queue and nowhere else. On Android 10+ the background activity start is refused, so nothing ever drained that queue and the app never learned it had been asked -- while the ordinary message branch beside it spools for exactly this case. The answer genuinely cannot be saved (the peer waits out a timeout measured in seconds), but the request is still information the app subscribed to: it is spooled as an ordinary one-shot message now, and the listener sees it on the next launch with expectsReply false, which is precisely true -- nobody is waiting any more. The local token is allocated only on the live path, so no origin is recorded for a reply that will never be sent. And the plist scan skips comments and CDATA when looking for the tags themselves, not only inside them. A fragment carrying an example -- -- is invisible to the phone's XML parser, but the raw search treated it as live: the watch took the commented version while the app it is embedded in kept its real one, and archive validation rejects that mismatch. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/WatchNativeBuilder.java | 40 +++++++++++++++++-- .../builders/wearable/CN1WearableBridge.java | 32 +++++++++++++++ .../wearable/CN1WearableListenerService.java | 8 +--- .../builders/WatchNativeBuilderTest.java | 27 +++++++++++++ 4 files changed, 98 insertions(+), 9 deletions(-) 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 1ab64f4b298..ba28ea55e1d 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 @@ -732,7 +732,7 @@ static java.util.List injectedPlistKeys(BuildRequest request) { } int at = 0; while (true) { - int open = inject.indexOf("", at); + int open = nextMarkup(inject, "", at); if (open < 0) { return out; } @@ -763,7 +763,7 @@ static String injectedPlistString(BuildRequest request, String key) { // different marketing versions, which archive validation rejects. int at = 0; while (true) { - int open = inject.indexOf("", at); + int open = nextMarkup(inject, "", at); if (open < 0) { return null; } @@ -776,7 +776,7 @@ static String injectedPlistString(BuildRequest request, String key) { inject.substring(open + "".length(), close)).trim())) { continue; } - int valueOpen = inject.indexOf("", at); + int valueOpen = nextMarkup(inject, "", at); if (valueOpen < 0) { return null; } @@ -798,6 +798,40 @@ private static int closeOfString(String inject, int from) { return closeOfElement(inject, from, ""); } + /// The next occurrence of a tag that is really markup, skipping comments and CDATA. + /// + /// A plist fragment routinely carries an EXAMPLE in a comment -- + /// {@code } -- and the phone's XML parser + /// ignores it. A raw indexOf did not: the watch took the commented version while the app it is + /// embedded in kept its real one, and archive validation rejects that mismatch. CDATA is the + /// same story from the other side: {@code x]]>} is text, not an element. + private static int nextMarkup(String inject, String tag, int from) { + int i = from; + while (i <= inject.length()) { + int at = inject.indexOf(tag, i); + if (at < 0) { + return -1; + } + int cdata = inject.indexOf(CDATA_OPEN, i); + int comment = inject.indexOf(COMMENT_OPEN, i); + boolean cdataFirst = cdata >= 0 && (comment < 0 || cdata < comment); + int skipFrom = cdataFirst ? cdata : comment; + if (skipFrom < 0 || skipFrom > at) { + return at; + } + String opener = cdataFirst ? CDATA_OPEN : COMMENT_OPEN; + String closer = cdataFirst ? CDATA_CLOSE : COMMENT_CLOSE; + int end = inject.indexOf(closer, skipFrom + opener.length()); + if (end < 0) { + // Unterminated: nothing after it can be located reliably, so the key is treated as + // absent rather than guessed at. + return -1; + } + i = end + closer.length(); + } + return -1; + } + /// The end tag that closes an element, skipping over CDATA sections and comments. private static int closeOfElement(String inject, int from, String closeTag) { int i = from; 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 0925ce07811..8e15b84c5bf 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 @@ -1400,6 +1400,38 @@ static void spoolOrDeliverMessage(Context context, String path, byte[] payload) } } + /// Hands a reply-bearing request to a live listener, or writes the PAYLOAD down for later. + /// + /// The answer cannot be saved for later -- the peer waits out a timeout measured in seconds and + /// is long gone by the next launch -- but the request itself is still information the app asked + /// to receive, and dropping it was the outcome before this. On Android 10+ the background + /// activity start is refused, so a cold-start request went into a process-local queue that + /// nothing would ever drain, and the app never learned it had been asked. + /// + /// So: delivered as a request while the app can answer, and spooled as an ordinary one-shot + /// message when it cannot. The listener then sees it on the next launch with `expectsReply` + /// false, which is exactly what it means -- nobody is waiting any more. + /// + /// The local token is allocated only on the live path. Trading the peer's token for one of ours + /// records an origin that would never be answered, and the reply-timeout bookkeeping that goes + /// with it has nothing to cancel. + static void spoolOrDeliverRequest(Context context, String path, byte[] payload, + int peerToken, String sourceNodeId) { + if (deliverableNow(context)) { + // The peer's token is unique only on the peer, so trade it for a locally unique one + // keyed to the node that asked; two watches can otherwise pick the same number. + int localToken = rememberRequestOrigin(peerToken, sourceNodeId); + WearableConnection.deliverMessage(path, payload, localToken); + return; + } + android.util.Log.w("CN1Wearable", "no listener can answer the request on " + path + + " right now; spooling it as a plain message, and the peer will time out"); + if (!spool(context, SPOOL_MESSAGE, path, payload)) { + int localToken = rememberRequestOrigin(peerToken, sourceNodeId); + WearableConnection.deliverMessage(path, payload, localToken); + } + } + /// The same for a removal, whose item no longer exists to be replayed from. static void spoolOrDeliverRemoval(Context context, String path) { if (deliverableNow(context)) { 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 72838adccc5..1db52cb23c5 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 @@ -132,18 +132,14 @@ private void handleMessageReceived(MessageEvent event) { } try { int peerToken = Integer.parseInt(rest.substring(0, slash)); - // The peer's token is unique only on the peer, so trade it for a locally unique one - // keyed to the node that asked; two watches can otherwise pick the same number. - int localToken = CN1WearableBridge.rememberRequestOrigin( - peerToken, event.getSourceNodeId()); // The path parsed and a peer is waiting for an answer, so this one is worth a // launch: without the app up nothing will ever answer it. ensureAppRunning(); // Past the delimiter, not onto it: the encoded application path escapes its own // slashes, so this one belongs to the wire format and is not part of the app's path. - WearableConnection.deliverMessage( + CN1WearableBridge.spoolOrDeliverRequest(getApplicationContext(), CN1WearableBridge.decode(rest.substring(slash + 1)), - event.getData(), localToken); + event.getData(), peerToken, event.getSourceNodeId()); } catch (NumberFormatException malformed) { // Not ours. } 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 1bcab140aa9..16277718f5d 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 @@ -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.assertTrue; /// Pins the watch build's contract with the project: declaring a watch lifecycle @@ -818,6 +819,32 @@ void injectedKeysAreMatchedAsContentNotAsMarkup(@TempDir Path tmp) throws IOExce "the watch carries the injected version, not its fallback: " + plist); } + /** + * A key inside an XML comment is not a key. + * + *

A plist fragment routinely carries an example in a comment, and the phone's parser ignores + * it -- so the watch taking the commented version while the app it embeds keeps its real one is + * a version mismatch archive validation rejects.

+ */ + @Test + void commentedOutKeysAreNotHonoured(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.plistInject", + ""); + + assertNull(WatchNativeBuilder.injectedPlistString(req, "CFBundleVersion"), + "a commented key is markup the phone's parser never sees"); + assertFalse(WatchNativeBuilder.injectedPlistKeys(req).contains("CFBundleVersion"), + "and the key scan has to agree with the lookup"); + + String plist = writeInfoPlist(req, tmp); + assertFalse(plist.contains("9.9"), + "the watch must not take a version from a comment: " + plist); + assertTrue(plist.contains("CFBundleVersion\n 2.5"), + "it keeps the project's own version: " + plist); + } + /// The bootstrap has to call the stub that actually exists in the watch binary. @Test void theBootstrapEntersTheWatchStub(@TempDir Path tmp) throws Exception { From 4b895fa82b9fcc1865258487cc450b11f46f6326 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:15:31 +0700 Subject: [PATCH 229/250] Wearables: drags reach the watch, and a drain hands off cleanly The watch root view recognised only a completed SpatialTapGesture, so the host saw a press and a release at one point and nothing in between. Every drag-driven control was inert while appearing to have been touched: a Slider could not move, a scrolled container could not be dragged, a swipe never fired. A DragGesture(minimumDistance: 0) forwards all three phases now -- the first onChanged is the press, the rest are drags, onEnded releases -- and covers a plain tap as the one gesture it replaces did, including the case where a gesture ends with no onChanged at all. Drain ownership had a gap at its very end. A worker seeing a drain in progress spools its event and does not start one, and if the owner cleared the flag before that write landed, nobody was left looking and the record waited for unrelated traffic or a restart. Writers raise a dirty flag under the same lock the owner checks before it stops, so the owner either loops or has not finished; and every writer now attempts a drain of its own, which is a no-op when an owner exists. An import the Swift compiler never sees is no longer read as a watch dependency. `#if os(iOS) import PhoneSDK #endif` attached an intentionally iOS-only package to the watch target and broke watchOS resolution over excluded code. Only a condition demonstrably not watchOS is dropped -- an os() test naming another platform, or one negating watchOS -- because dropping an import the watch does need is the worse failure and the one with no explanation attached. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/WatchNativeBuilder.java | 90 +++++++++++++++++-- .../builders/wearable/CN1WearableBridge.java | 40 +++++++-- .../builders/WatchNativeBuilderTest.java | 29 ++++++ 3 files changed, 149 insertions(+), 10 deletions(-) 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 ba28ea55e1d..b6cdbbad9e3 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 @@ -518,6 +518,7 @@ void writeWatchEntry(BuildRequest request, File appSrcDir) throws IOException { .append("struct CN1WatchRootView: View {\n") .append(" @StateObject private var model = CN1WatchFrameModel()\n") .append(" @State private var crown: Double = 0\n") + .append(" @State private var dragging = false\n") .append(" var body: some View {\n") .append(" GeometryReader { geo in\n") .append(" ZStack {\n") @@ -532,9 +533,40 @@ void writeWatchEntry(BuildRequest request, File appSrcDir) throws IOException { .append(" .onChange(of: crown) { oldValue, newValue in\n") .append(" CN1WatchHost.shared().crownRotated(by: newValue - oldValue)\n") .append(" }\n") - .append(" .gesture(SpatialTapGesture().onEnded { e in\n") - .append(" CN1WatchHost.shared().tapAt(x: Int32(e.location.x), y: Int32(e.location.y))\n") - .append(" })\n") + // A DRAG gesture, with a zero minimum distance, so it also covers a plain tap. + // + // SpatialTapGesture reports only a completed tap, at its final point, which the host + // turned into a press immediately followed by a release. Nothing in between ever reached + // pointerDraggedToX, so a Slider could not be moved, a scrollable container could not be + // dragged, and a swipe gesture never fired -- every drag-driven control on the watch was + // inert while looking like it had been touched. + // + // onChanged fires continuously from the moment the finger lands, so the FIRST one is the + // press and the rest are drags; the flag is what tells them apart, and onEnded clears it + // after the release. A tap produces exactly one onChanged and one onEnded, which is the + // press/release pair it produced before. + .append(" .gesture(DragGesture(minimumDistance: 0)\n") + .append(" .onChanged { e in\n") + .append(" let x = Int32(e.location.x)\n") + .append(" let y = Int32(e.location.y)\n") + .append(" if dragging {\n") + .append(" CN1WatchHost.shared().pointerDragged(toX: x, y: y)\n") + .append(" } else {\n") + .append(" dragging = true\n") + .append(" CN1WatchHost.shared().pointerPressed(atX: x, y: y)\n") + .append(" }\n") + .append(" }\n") + .append(" .onEnded { e in\n") + .append(" let x = Int32(e.location.x)\n") + .append(" let y = Int32(e.location.y)\n") + // Press first if onChanged never ran: a gesture can end without one, and a release with + // no press leaves the CN1 event stream unbalanced. + .append(" if !dragging {\n") + .append(" CN1WatchHost.shared().pointerPressed(atX: x, y: y)\n") + .append(" }\n") + .append(" dragging = false\n") + .append(" CN1WatchHost.shared().pointerReleased(atX: x, y: y)\n") + .append(" })\n") .append(" .ignoresSafeArea()\n") .append(" .onAppear {\n") .append(" let d = WKInterfaceDevice.current()\n") @@ -1945,6 +1977,54 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // conceptually its own list even though the pbxproj format would tolerate one object in // two. // + // Imports the Swift compiler will not see do not make a package a watch dependency. + // + // A staged source guarding an iOS-only package with `#if os(iOS) import PhoneSDK #endif` + // still contains the word `import PhoneSDK`, and a raw text match read that as a watch + // dependency -- attaching a package that intentionally supports only iOS and breaking + // watchOS resolution over code the compiler excludes. + // + // Only a condition that is DEMONSTRABLY not watchOS is dropped: an os() test naming other + // platforms, or one negating watchOS. Anything else -- a custom flag, a compiler-version + // test, an expression this cannot evaluate -- is kept, because dropping an import the watch + // does need is the worse failure of the two and the one that produces no explanation. + s.append("def cn1_watch_strip_non_watch(src)\n") + .append(" out = []\n") + .append(" suppressed_at = nil\n") + .append(" depth = 0\n") + .append(" src.each_line do |line|\n") + .append(" t = line.strip\n") + .append(" if t.start_with?('#if')\n") + .append(" depth += 1\n") + .append(" if suppressed_at.nil? && cn1_watch_excludes_watch(t)\n") + .append(" suppressed_at = depth\n") + .append(" end\n") + .append(" next\n") + .append(" elsif t.start_with?('#elseif') || t.start_with?('#else')\n") + // The other arm of a branch we suppressed is the arm that DOES apply to watchOS, + // unless it too names a platform that is not one. + .append(" if suppressed_at == depth\n") + .append(" suppressed_at = cn1_watch_excludes_watch(t) ? depth : nil\n") + .append(" end\n") + .append(" next\n") + .append(" elsif t.start_with?('#endif')\n") + .append(" suppressed_at = nil if suppressed_at == depth\n") + .append(" depth -= 1 if depth > 0\n") + .append(" next\n") + .append(" end\n") + .append(" out << line if suppressed_at.nil?\n") + .append(" end\n") + .append(" out.join\n") + .append("end\n") + .append("def cn1_watch_excludes_watch(condition)\n") + .append(" return false unless condition.include?('os(')\n") + // Positively naming watchOS keeps the block; negating it drops the block. + .append(" return true if condition =~ /!\\s*os\\(\\s*watchOS\\s*\\)/\n") + .append(" return false if condition.include?('os(watchOS)')\n") + .append(" condition =~ /os\\(\\s*(iOS|macOS|tvOS|visionOS|Linux|Windows|Android)" + + "\\s*\\)/ ? true : false\n") + .append("end\n"); + // Mirrored only when the WATCH sources actually import the product. // // No SDK check is possible here, unlike the system frameworks above: a package declares its @@ -1980,13 +2060,13 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, .append(" Dir.glob(File.join(watch_import_src, '**', '*.{h,m,mm,c,cpp,cc,swift}'))" + ".each do |f|\n") .append(" begin\n") - .append(" watch_import_text << File.read(f)\n") + .append(" watch_import_text << cn1_watch_strip_non_watch(File.read(f))\n") .append(" rescue StandardError\n") .append(" next\n") .append(" end\n") .append(" end\n") .append("end\n") - // A product name is not always its module name. + // A product name is not always its module name. // // A package may export product FooKit containing target Foo, and staged code then // says `import Foo` -- which matches no product, so the strict gate concluded FooKit 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 8e15b84c5bf..e486945f073 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 @@ -1397,7 +1397,11 @@ static void spoolOrDeliverMessage(Context context, String path, byte[] payload) // The spool is the durable half; if it could not be written, the in-memory queue is // still better than dropping the message outright. WearableConnection.deliverMessage(path, payload, 0); + return; } + // Whoever writes tries to drain. If an owner already holds the walk this returns at once, + // and the dirty flag raised above is what makes that owner come back for this record. + drainSpool(context); } /// Hands a reply-bearing request to a live listener, or writes the PAYLOAD down for later. @@ -1429,7 +1433,9 @@ static void spoolOrDeliverRequest(Context context, String path, byte[] payload, if (!spool(context, SPOOL_MESSAGE, path, payload)) { int localToken = rememberRequestOrigin(peerToken, sourceNodeId); WearableConnection.deliverMessage(path, payload, localToken); + return; } + drainSpool(context); } /// The same for a removal, whose item no longer exists to be replayed from. @@ -1440,7 +1446,9 @@ static void spoolOrDeliverRemoval(Context context, String path) { } if (!spool(context, SPOOL_REMOVAL, path, null)) { WearableConnection.deliverDataRemoved(path); + return; } + drainSpool(context); } /// Whether this process can run app code AND nothing older is still waiting to be replayed. @@ -1496,6 +1504,9 @@ private static boolean spool(Context context, String kind, String path, byte[] p + (payload == null ? "" : android.util.Base64.encodeToString( payload, android.util.Base64.NO_WRAP))); trimSpool(prefs, edit); + // Under the lock, and before the commit: an owner finishing its last pass has to + // see this rather than stop with the record unread. + spoolDirty = true; // commit(), and its result: the caller falls back to the in-memory queue when the // write did not land, so an ignored false would silently lose exactly what this // exists for. @@ -1518,6 +1529,15 @@ private static boolean spool(Context context, String kind, String path, byte[] p /// the store is empty, so anything written meanwhile is picked up before it finishes. private static boolean spoolDraining; + /// Set by every writer, cleared by the owner only when it is about to stop. + /// + /// Ownership alone leaves a gap at the very end: a worker that sees a drain in progress spools + /// its event and does not start one, and if the owner clears the flag before that write lands, + /// nobody is left looking. The record then waits for unrelated traffic or a restart. Raising + /// this under the same lock the owner checks closes it -- the owner either sees the flag and + /// loops, or has not finished yet and the writer's record is in a pass still to come. + private static boolean spoolDirty; + /// Replays everything written down, oldest first, forgetting each only once it is delivered. static void drainSpool(Context context) { synchronized (SPOOL_LOCK) { @@ -1527,15 +1547,25 @@ static void drainSpool(Context context) { spoolDraining = true; } try { - while (drainSpoolPass(context)) { - // Again, because a worker that wrote a record while this pass was running returned - // without draining it -- this owner is the one that has to pick it up. - continue; + while (true) { + drainSpoolPass(context); + synchronized (SPOOL_LOCK) { + if (!spoolDirty) { + // Nothing arrived while that pass ran, and no writer can slip in behind + // this: a writer raises the flag under this same lock, so it either did so + // before this check -- and the loop continues -- or it is still waiting to + // take the lock, and will find spoolDraining false and drain for itself. + spoolDraining = false; + return; + } + spoolDirty = false; + } } - } finally { + } catch (RuntimeException failed) { synchronized (SPOOL_LOCK) { spoolDraining = false; } + throw failed; } } 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 16277718f5d..7edbd23c201 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 @@ -845,6 +845,35 @@ void commentedOutKeysAreNotHonoured(@TempDir Path tmp) throws IOException { "it keeps the project's own version: " + plist); } + /** + * Every phase of a drag reaches the pointer pipeline, not just the completed tap. + * + *

A SpatialTapGesture reports one point when the finger lifts, which the host turned into a + * press immediately followed by a release -- so nothing reached {@code pointerDraggedToX} and + * every drag-driven control was inert: a Slider could not be moved, a scrollable container + * could not be dragged, a swipe never fired.

+ */ + @Test + void dragPhasesReachTheWatchPointerPipeline(@TempDir Path tmp) throws Exception { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + File dir = tmp.toFile(); + parse(req).writeWatchEntry(req, dir); + String swift = read(new File(dir, "CN1WatchApp.swift")); + + // Zero minimum distance, so the same gesture still covers a plain tap. + assertTrue(swift.contains("DragGesture(minimumDistance: 0)"), swift); + assertTrue(swift.contains("pointerPressed(atX:"), swift); + assertTrue(swift.contains("pointerDragged(toX:"), + "the middle of a drag has to arrive, or a Slider cannot move: " + swift); + assertTrue(swift.contains("pointerReleased(atX:"), swift); + // A press exactly once per gesture: the flag is what separates the first onChanged from + // the rest, and it also covers a gesture that ends without any onChanged at all. + assertTrue(swift.contains("if dragging {") && swift.contains("if !dragging {"), swift); + assertFalse(swift.contains("SpatialTapGesture"), + "the tap-only gesture is what could not express a drag: " + swift); + } + /// The bootstrap has to call the stub that actually exists in the watch binary. @Test void theBootstrapEntersTheWatchStub(@TempDir Path tmp) throws Exception { From 43055d1c7fc522c9340ef25ec727bfa2668a8941 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:22:35 +0700 Subject: [PATCH 230/250] Wearables: once a watchOS arm is taken, the other arms are excluded too `#if os(watchOS) ... #else ... #endif` kept BOTH arms. A single "am I suppressed" flag only ever switched arms when the FIRST one was suppressed, so a watch-first branch never suppressed its else -- and an iOS-only package imported only by that else was mirrored onto the watch target, breaking watchOS resolution over code the compiler excludes there. The state is per nesting level now, and it records two different facts: whether this arm is suppressed, and whether an arm that POSITIVELY applies to watchOS has already been taken. The second is what closes the else. A condition that cannot be evaluated -- a custom flag, an ObjC macro, a compiler-version test -- still keeps every arm, because guessing either way risks dropping an import the watch needs. Ten shapes checked against the generated stripper: watch-first with and without an else, iOS-first, negated watchOS, elseif chains selecting either platform, a nested conditional inside a watch arm, an unevaluatable condition keeping both arms, and a file with no conditionals at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/WatchNativeBuilder.java | 45 +++++++++++++------ .../builders/WatchNativeBuilderTest.java | 7 +++ 2 files changed, 39 insertions(+), 13 deletions(-) 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 b6cdbbad9e3..eec82fa964d 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 @@ -1990,39 +1990,58 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // does need is the worse failure of the two and the one that produces no explanation. s.append("def cn1_watch_strip_non_watch(src)\n") .append(" out = []\n") - .append(" suppressed_at = nil\n") - .append(" depth = 0\n") + // A STACK per level, and two facts about each: whether this arm is suppressed, and + // whether an arm that positively applies to watchOS has already been taken. The + // second is what makes `#if os(watchOS) ... #else ... #endif` drop its else -- a + // single "am I suppressed" flag only ever switched arms when the FIRST one was + // suppressed, so both arms of a watch-first branch were kept and an iOS-only + // package imported by the else was mirrored onto the watch. + .append(" suppressed = []\n") + .append(" decided = []\n") .append(" src.each_line do |line|\n") .append(" t = line.strip\n") .append(" if t.start_with?('#if')\n") - .append(" depth += 1\n") - .append(" if suppressed_at.nil? && cn1_watch_excludes_watch(t)\n") - .append(" suppressed_at = depth\n") + .append(" if cn1_watch_excludes_watch(t)\n") + .append(" suppressed << true; decided << false\n") + .append(" elsif cn1_watch_selects_watch(t)\n") + .append(" suppressed << false; decided << true\n") + .append(" else\n") + // Unevaluatable -- a custom flag, an ObjC macro, a compiler-version test. Both arms + // are kept, because guessing either way risks dropping an import the watch needs. + .append(" suppressed << false; decided << false\n") .append(" end\n") .append(" next\n") .append(" elsif t.start_with?('#elseif') || t.start_with?('#else')\n") - // The other arm of a branch we suppressed is the arm that DOES apply to watchOS, - // unless it too names a platform that is not one. - .append(" if suppressed_at == depth\n") - .append(" suppressed_at = cn1_watch_excludes_watch(t) ? depth : nil\n") + .append(" next if suppressed.empty?\n") + .append(" i = suppressed.length - 1\n") + .append(" if decided[i]\n") + .append(" suppressed[i] = true\n") + .append(" elsif cn1_watch_excludes_watch(t)\n") + .append(" suppressed[i] = true\n") + .append(" else\n") + .append(" suppressed[i] = false\n") + .append(" decided[i] = true\n") .append(" end\n") .append(" next\n") .append(" elsif t.start_with?('#endif')\n") - .append(" suppressed_at = nil if suppressed_at == depth\n") - .append(" depth -= 1 if depth > 0\n") + .append(" suppressed.pop; decided.pop\n") .append(" next\n") .append(" end\n") - .append(" out << line if suppressed_at.nil?\n") + .append(" out << line unless suppressed.any?\n") .append(" end\n") .append(" out.join\n") .append("end\n") .append("def cn1_watch_excludes_watch(condition)\n") .append(" return false unless condition.include?('os(')\n") - // Positively naming watchOS keeps the block; negating it drops the block. .append(" return true if condition =~ /!\\s*os\\(\\s*watchOS\\s*\\)/\n") .append(" return false if condition.include?('os(watchOS)')\n") .append(" condition =~ /os\\(\\s*(iOS|macOS|tvOS|visionOS|Linux|Windows|Android)" + "\\s*\\)/ ? true : false\n") + .append("end\n") + /// Positively naming watchOS, which is what lets the other arms be dropped. + .append("def cn1_watch_selects_watch(condition)\n") + .append(" condition.include?('os(watchOS)') && " + + "!(condition =~ /!\\s*os\\(\\s*watchOS\\s*\\)/)\n") .append("end\n"); // Mirrored only when the WATCH sources actually import the product. 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 7edbd23c201..187017acf34 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 @@ -717,6 +717,13 @@ void swiftPackageProductsReachTheWatchTarget(@TempDir Path tmp) throws Exception // watchOS framework) and steps aside rather than withholding a module the sources import. assertTrue(ruby.contains("strict_products"), ruby); assertTrue(ruby.contains("unattributed"), ruby); + // Conditional-compilation regions: an import the Swift compiler excludes is not a watch + // dependency, and once a watchOS arm is taken the other arms are excluded too -- a single + // "am I suppressed" flag kept both arms of `#if os(watchOS) ... #else ... #endif`. + assertTrue(ruby.contains("cn1_watch_strip_non_watch"), ruby); + assertTrue(ruby.contains("decided << true") && ruby.contains("decided[i]"), + "the taken-arm state is what drops the else of a watch-first branch: " + ruby); + assertTrue(ruby.contains("cn1_watch_selects_watch"), ruby); assertTrue(ruby.contains("matches no product name"), "stepping aside has to be logged, or a mirrored iOS-only package looks arbitrary: " + ruby); From 0aebc3d59c4332a057ffff20a8b819a7e1094703 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:33:10 +0700 Subject: [PATCH 231/250] Wearables: the crown scrolls a usable distance, and unevaluable arms all survive The Digital Crown moved a form by ONE POINT per detent. The Swift view asks for crown values `by: 1`, and that unit went straight to the wheel callback as a pixel delta -- the Android rotary path multiplies by the platform's scroll factor, and watchOS has no equivalent to consult. A line-height-sized step per unit replaces it, and the fractional remainder is carried between events: truncating each one independently threw away every rotation smaller than a point, so a slow deliberate turn produced a stream of zeroes and scrolled nothing. An unevaluable `#elseif` no longer closes its branch. `#if FEATURE_A / #elseif FEATURE_B / #else import FallbackSDK` suppressed the else, and when both flags are off that else is the arm the watch actually compiles -- so its package was dropped and the target failed on a missing module. Only an arm that demonstrably selects watchOS decides a branch now. And an import inside a comment is not an import. A documentation example or a commented-out line still contained the words the regexes look for, so an iOS-only product named in one was attached to the watch target over code that does not exist. Seventeen shapes checked against the generated stripper, including the ten from the previous round to confirm none of them regressed. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/CN1WatchHost.m | 27 ++++++++++++++++++- .../builders/WatchNativeBuilder.java | 19 ++++++++++++- .../builders/WatchNativeBuilderTest.java | 6 +++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1WatchHost.m b/Ports/iOSPort/nativeSources/CN1WatchHost.m index 1bc21510d21..a66ceb20fb9 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchHost.m +++ b/Ports/iOSPort/nativeSources/CN1WatchHost.m @@ -134,15 +134,40 @@ - (void)applicationWillEnterForeground { #pragma mark - Input +/// Logical points a container scrolls per unit of crown rotation. +/// +/// The Swift view asks for crown values `by: 1`, so an ordinary detent arrives as roughly 1.0. +/// Forwarding that straight through moved a form by ONE POINT per detent, which is a scroll bar +/// that never visibly moves -- the Android rotary path does not do this, it multiplies by the +/// platform's own scroll factor. watchOS exposes no equivalent, so this is the line-height-sized +/// step that factor amounts to elsewhere. +static const CGFloat CN1_WATCH_CROWN_POINTS_PER_UNIT = 24.0; + +/// What was left over after the last whole-point delivery. +/// +/// The wheel callback takes an int, and truncating each event independently threw away every +/// rotation smaller than a point -- a slow, deliberate turn produced a stream of zeroes and +/// scrolled nothing at all. Carrying the remainder makes the small movements add up to the same +/// distance as one fast turn. +static CGFloat cn1WatchCrownRemainder = 0; + - (void)crownRotatedBy:(CGFloat)crownDelta { // Route the Digital Crown through the cross-platform wheel pipeline so it is the same universal // scroll-gesture input as a mouse wheel or trackpad: it scrolls the component under the center // of the watch face and is also delivered to any mouse wheel listeners as a WheelEvent. A // positive crown delta reveals content above (scrolls down), matching the wheel convention. + CGFloat scaled = (-crownDelta * CN1_WATCH_CROWN_POINTS_PER_UNIT) + cn1WatchCrownRemainder; + int whole = (int)scaled; + cn1WatchCrownRemainder = scaled - (CGFloat)whole; + if (whole == 0) { + // Nothing to deliver yet, and nothing to repaint: the remainder is holding the movement + // until it amounts to a point. + return; + } needsDisplay = YES; int cx = _renderingView != nil ? [_renderingView logicalWidth] / 2 : 0; int cy = _renderingView != nil ? [_renderingView logicalHeight] / 2 : 0; - pointerWheelMovedCallback(cx, cy, 0, (int)(-crownDelta)); + pointerWheelMovedCallback(cx, cy, 0, whole); } - (void)tapAtX:(int)x y:(int)y { 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 eec82fa964d..dac07c02857 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 @@ -1988,7 +1988,20 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // platforms, or one negating watchOS. Anything else -- a custom flag, a compiler-version // test, an expression this cannot evaluate -- is kept, because dropping an import the watch // does need is the worse failure of the two and the one that produces no explanation. + // Comments first: an import the compiler never sees is not a dependency. + // + // A documentation example or a commented-out line -- `// import PhoneSDK`, or an ObjC + // `/* #import */` -- still contains the words the import regexes look + // for, and an iOS-only product named in one was mirrored onto the watch target over code + // that does not exist. Block comments are removed wholesale and a line comment to the end of + // its line, which is where an import can legally appear and a comment cannot hide anything + // else that matters here. + s.append("def cn1_watch_strip_comments(src)\n") + .append(" src.gsub(/\\/\\*.*?\\*\\//m, ' ').gsub(/\\/\\/[^\\n]*/, '')\n") + .append("end\n"); + s.append("def cn1_watch_strip_non_watch(src)\n") + .append(" src = cn1_watch_strip_comments(src)\n") .append(" out = []\n") // A STACK per level, and two facts about each: whether this arm is suppressed, and // whether an arm that positively applies to watchOS has already been taken. The @@ -2020,7 +2033,11 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, .append(" suppressed[i] = true\n") .append(" else\n") .append(" suppressed[i] = false\n") - .append(" decided[i] = true\n") + // ONLY a demonstrably watchOS arm closes the branch. Marking an unevaluable + // `#elseif FEATURE_B` as decided suppressed the `#else` behind it -- and when both + // flags are off that else is the arm the watch compiles, so its import was dropped + // and the target failed on a missing module. + .append(" decided[i] = true if cn1_watch_selects_watch(t)\n") .append(" end\n") .append(" next\n") .append(" elsif t.start_with?('#endif')\n") 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 187017acf34..30314d6a313 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 @@ -724,6 +724,12 @@ void swiftPackageProductsReachTheWatchTarget(@TempDir Path tmp) throws Exception assertTrue(ruby.contains("decided << true") && ruby.contains("decided[i]"), "the taken-arm state is what drops the else of a watch-first branch: " + ruby); assertTrue(ruby.contains("cn1_watch_selects_watch"), ruby); + // Only a demonstrably watchOS arm closes a branch: marking an unevaluable #elseif as + // decided suppressed the #else, which is the arm the watch compiles when the flags are off. + assertTrue(ruby.contains("decided[i] = true if cn1_watch_selects_watch(t)"), ruby); + // And an import the compiler never sees is not a dependency at all. + assertTrue(ruby.contains("cn1_watch_strip_comments"), + "a commented-out import must not attach its package to the watch: " + ruby); assertTrue(ruby.contains("matches no product name"), "stepping aside has to be logged, or a mirrored iOS-only package looks arbitrary: " + ruby); From 648d82d4f4fc85d3c44aa4fe9138e8d300c65df7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:32:10 +0700 Subject: [PATCH 232/250] Wearables: the drag calls use the names Swift actually imports I broke every iOS job. The pointer selectors import as pointerPressedAt(x:y:), pointerDraggedTo(x:y:) and pointerReleasedAt(x:y:) -- the same shape as the tapAtX:y: -> tapAt(x:y:) call that was already there and already worked. I guessed pointerPressed(atX:) instead, which is valid Swift that resolves to nothing, so the generated CN1WatchApp.swift failed to compile and took build-ios, build-ios-metal, build-ios-watch, native-ios and packaging with it. My check was the hole: swiftc -parse validates syntax and never looks a name up. The generated calls are now type-checked against a real Objective-C header, which is what would have caught this, and the imported names are pinned in the unit test so a future guess fails locally rather than in CI. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/WatchNativeBuilder.java | 8 ++++---- .../com/codename1/builders/WatchNativeBuilderTest.java | 10 +++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) 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 dac07c02857..ebdccbe530e 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 @@ -550,10 +550,10 @@ void writeWatchEntry(BuildRequest request, File appSrcDir) throws IOException { .append(" let x = Int32(e.location.x)\n") .append(" let y = Int32(e.location.y)\n") .append(" if dragging {\n") - .append(" CN1WatchHost.shared().pointerDragged(toX: x, y: y)\n") + .append(" CN1WatchHost.shared().pointerDraggedTo(x: x, y: y)\n") .append(" } else {\n") .append(" dragging = true\n") - .append(" CN1WatchHost.shared().pointerPressed(atX: x, y: y)\n") + .append(" CN1WatchHost.shared().pointerPressedAt(x: x, y: y)\n") .append(" }\n") .append(" }\n") .append(" .onEnded { e in\n") @@ -562,10 +562,10 @@ void writeWatchEntry(BuildRequest request, File appSrcDir) throws IOException { // Press first if onChanged never ran: a gesture can end without one, and a release with // no press leaves the CN1 event stream unbalanced. .append(" if !dragging {\n") - .append(" CN1WatchHost.shared().pointerPressed(atX: x, y: y)\n") + .append(" CN1WatchHost.shared().pointerPressedAt(x: x, y: y)\n") .append(" }\n") .append(" dragging = false\n") - .append(" CN1WatchHost.shared().pointerReleased(atX: x, y: y)\n") + .append(" CN1WatchHost.shared().pointerReleasedAt(x: x, y: y)\n") .append(" })\n") .append(" .ignoresSafeArea()\n") .append(" .onAppear {\n") 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 30314d6a313..6a89ed18a97 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 @@ -876,10 +876,14 @@ void dragPhasesReachTheWatchPointerPipeline(@TempDir Path tmp) throws Exception // Zero minimum distance, so the same gesture still covers a plain tap. assertTrue(swift.contains("DragGesture(minimumDistance: 0)"), swift); - assertTrue(swift.contains("pointerPressed(atX:"), swift); - assertTrue(swift.contains("pointerDragged(toX:"), + // The names Swift actually imports these ObjC selectors under. pointerPressedAtX:y: + // becomes pointerPressedAt(x:y:), the same shape as the tapAtX:y: call that already + // worked -- guessing pointerPressed(atX:) instead compiled as valid Swift and failed to + // resolve, which broke every iOS job. + assertTrue(swift.contains("pointerPressedAt(x:"), swift); + assertTrue(swift.contains("pointerDraggedTo(x:"), "the middle of a drag has to arrive, or a Slider cannot move: " + swift); - assertTrue(swift.contains("pointerReleased(atX:"), swift); + assertTrue(swift.contains("pointerReleasedAt(x:"), swift); // A press exactly once per gesture: the flag is what separates the first onChanged from // the rest, and it also covers a gesture that ends without any onChanged at all. assertTrue(swift.contains("if dragging {") && swift.contains("if !dragging {"), swift); From 2cca8f6d5e56744d203c65c3035ac13c4c0439f6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:40:47 +0700 Subject: [PATCH 233/250] Wearables: an unreceived message stays parked, and one rule decides legacy Wear mode A message whose listener snapshot emptied between the dispatch and the EDT runnable ran through to the delivery callback. Nobody had received it, but the port was told otherwise -- and on Android that callback releases the durable spool record, so the one-shot was deleted with no listener having seen it and no way to replay it. It is parked again instead, exactly as the tracked data path already does for the same race, and the empty-snapshot reply guard folds into that: an app with no listener left answers nothing, and the sender's own timeout is the honest report. The manifest and the lifecycle selection disagreed about the retired android.wear hint. A migrated project keeping android.wear=true, adding a watchMain and leaving watchStandalone false is asking for a COMPANION -- but the legacy flag still forced the required watch feature and the standalone marker, while appLifecycleClass read only the new settings and rooted the result at the phone lifecycle. A Wear-only APK running the phone app is neither of the two things that project could have meant. Both now consult one rule: the new declaration governs wherever watchMain exists, and a project that never declared one keeps the legacy behaviour it always had. Co-Authored-By: Claude Opus 5 (1M context) --- .../wearable/WearableConnection.java | 20 +++++++---- .../builders/AndroidGradleBuilder.java | 33 +++++++++++++++++-- .../builders/AndroidLegacyWearHintTest.java | 25 ++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/wearable/WearableConnection.java b/CodenameOne/src/com/codename1/wearable/WearableConnection.java index 3df9d3b59c1..c7355ffcb58 100644 --- a/CodenameOne/src/com/codename1/wearable/WearableConnection.java +++ b/CodenameOne/src/com/codename1/wearable/WearableConnection.java @@ -534,6 +534,15 @@ public void run() { WearableMessage reply = null; WearableMessageListener[] copy = messageListenerSnapshot(); + if (copy.length == 0) { + // The snapshot emptied between the dispatch and this runnable -- an app + // shutting down, or one that deregisters on pause. Parked again rather than + // run through: nobody received the message, so the port must NOT be told it + // was delivered. It would release its durable record on that word and the + // one-shot would be gone, which is the failure the record exists to prevent. + deliver(this, messageListeners, pendingMessages); + return; + } // Isolated per listener, as every other dispatch here is. A live message cannot be // replayed, so a listener skipped because an EARLIER one threw simply never sees // it -- and for a request the sender waits out its whole timeout even when a later @@ -551,12 +560,11 @@ public void run() { } } } - // Nothing answers for an app that has no listener left. The snapshot can empty - // between the dispatch and this runnable -- an app shutting down, or one that - // deregisters on pause -- and replying anyway handed the sender an empty SUCCESS, - // so replyReceived fired for a request no application code ever saw. Staying - // silent lets the sender's own timeout report the failure it actually had. - if (replyToken != 0 && copy.length > 0) { + // An app with no listener left answers nothing, which the re-park above now + // handles: replying anyway handed the sender an empty SUCCESS, so replyReceived + // fired for a request no application code ever saw, and the sender's own timeout + // is the honest report of what happened. + if (replyToken != 0) { WearableBridge b = bridge(); if (b != null) { b.sendReply(replyToken, 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 88d5f2b9128..cbf4c3bdf42 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 @@ -331,6 +331,19 @@ private static String watchMainClass(BuildRequest request) { return request.getArg("watchMain", "").trim(); } + /// 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 + /// manifest honoured android.wear on its own, while the lifecycle read only the new settings -- + /// so a migrated project asking for a COMPANION got a Wear-only APK rooted at the phone + /// lifecycle, which is neither thing it could have meant. + /// + /// A project that has not declared watchMain has not migrated, and its legacy hints keep + /// working exactly as they did. + static boolean watchMainRetiresLegacyWear(String watchMain, String watchStandalone) { + return watchMain != null && watchMain.trim().length() > 0; + } + private String appLifecycleClass(BuildRequest request) { // Unit-test mode wins. generateUnitTestFiles has already replaced the main class with // CodenameOneUnitTestExecutor, and rooting the APK at the watch lifecycle instead started @@ -1452,10 +1465,26 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc // phone project carrying a stray android.wear.standalone=true would otherwise be given the // API 23 floor and a REQUIRED android.hardware.type.watch feature, and Play would filter // that APK off every phone -- a working phone app made undeliverable, with no error. - boolean legacyWear = legacyWearMode(request.getArg("android.wear", "false")); - boolean legacyStandaloneStillOn = legacyWearStandalone( + // + // And the legacy flag yields entirely once watchMain is declared. A migrated project that + // adds watchMain and leaves watchStandalone false is asking for a COMPANION, but the old + // flag still forced the required watch feature and the standalone marker -- producing a + // Wear-only APK, while appLifecycleClass (which reads only the new settings) rooted it at + // the PHONE lifecycle. A watch-only artifact running the phone app is not either of the two + // things the project could have meant. One rule now governs both: the new declaration wins + // where it exists, and the legacy flag keeps working exactly as before where it does not. + boolean migrated = watchMainRetiresLegacyWear(watchMain, + request.getArg("watchStandalone", "false")); + boolean legacyWear = !migrated + && legacyWearMode(request.getArg("android.wear", "false")); + boolean legacyStandaloneStillOn = !migrated && legacyWearStandalone( request.getArg("android.wear", "false"), request.getArg("android.wear.standalone", "")); + if (migrated && legacyWearMode(request.getArg("android.wear", "false"))) { + log("[wearable] ignoring android.wear: codename1.watchMain is declared, so" + + " codename1.watchStandalone alone decides whether this is a standalone watch" + + " app or a companion."); + } if (legacyWear) { log("[wearable] android.wear is superseded by codename1.watchMain plus " + "codename1.watchStandalone; still honoured, but the new settings also build " diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidLegacyWearHintTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidLegacyWearHintTest.java index 06e59122083..6f4a18fed3e 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidLegacyWearHintTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidLegacyWearHintTest.java @@ -39,6 +39,31 @@ class AndroidLegacyWearHintTest { /** The regression: a stray standalone sub-hint must not turn a phone build into a Wear build. */ + /** + * The new declaration governs once it exists. + * + *

A migrated project that keeps {@code android.wear=true}, adds a {@code watchMain} and + * leaves {@code watchStandalone} false is asking for a companion. The legacy flag still forced + * the required watch feature and the standalone marker, producing a Wear-only APK -- while the + * lifecycle selection, which reads only the new settings, rooted it at the PHONE lifecycle. A + * watch-only artifact running the phone app is neither of the two things the project could have + * meant.

+ */ + @Test + void aDeclaredWatchMainRetiresTheLegacyWearFlag() { + // The helpers themselves are unchanged -- they still describe what the legacy hints meant. + assertTrue(AndroidGradleBuilder.legacyWearMode("true")); + assertTrue(AndroidGradleBuilder.legacyWearStandalone("true", "")); + // What changed is that the builder consults them only when no watchMain is declared, which + // is the single rule the manifest and the lifecycle selection now share. + assertFalse(AndroidGradleBuilder.watchMainRetiresLegacyWear("", "false"), + "no watchMain: the legacy flag still governs, as it always did"); + assertTrue(AndroidGradleBuilder.watchMainRetiresLegacyWear("com.acme.WatchApp", "false"), + "a companion watchMain must not be overridden into a standalone Wear APK"); + assertTrue(AndroidGradleBuilder.watchMainRetiresLegacyWear("com.acme.WatchApp", "true"), + "and the same rule applies when the new settings ask for standalone"); + } + @Test void standaloneAloneDoesNotEnableWearMode() { assertFalse(AndroidGradleBuilder.legacyWearMode("false")); From 0d7e02cbfcd127cc8229fe9a59ff9cd67366d48d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:15:25 +0700 Subject: [PATCH 234/250] Wearables: reply tokens survive a restart, and only a bare watchOS test closes a branch Reply tokens counted from 1 in every process, and the Android wire path carries nothing but the integer -- so a sender killed with request 1 outstanding, restarted, and issuing another request before the peer answered could have the STALE reply complete the new request's handler with the wrong payload. Nothing downstream can tell the two apart, so the token has to: it is seeded from the clock now, which is also monotonic across restarts unless a process issued more requests than milliseconds have passed, and wraps back to 1 rather than through 0 because 0 is the "no answer wanted" marker. And `#if os(watchOS) && FEATURE` no longer counts as a selected arm. It mentions watchOS and is not therefore true: with FEATURE off Swift compiles the `#else`, and treating the first arm as selected suppressed that else and dropped a package imported only there. Selection is the one direction that can silence another arm, so it now takes the whole expression being demonstrably true. Exclusion gained the matching guard from the other side: a disjunction can still be true on the watch through its other operand, so an os() test inside an `||` proves nothing, while a conjunction stays safe to exclude. Co-Authored-By: Claude Opus 5 (1M context) --- .../wearable/WearableConnection.java | 19 ++++++++++++++++++- .../builders/WatchNativeBuilder.java | 17 ++++++++++++++--- .../builders/WatchNativeBuilderTest.java | 8 ++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/wearable/WearableConnection.java b/CodenameOne/src/com/codename1/wearable/WearableConnection.java index c7355ffcb58..1e3a0e57037 100644 --- a/CodenameOne/src/com/codename1/wearable/WearableConnection.java +++ b/CodenameOne/src/com/codename1/wearable/WearableConnection.java @@ -87,7 +87,19 @@ public final class WearableConnection { /// alongside the handler so the reply decodes onto a real path -- a payload has to have one. private static final Map pendingReplies = new HashMap(); - private static int nextReplyToken = 1; + /// Reply tokens, seeded from the clock so a restart cannot reissue the previous run's. + /// + /// This counted from 1 in every process, and the Android wire path carries nothing but the + /// integer -- so a sender killed with request 1 outstanding, restarted, issuing another request + /// before the peer answered, could have the STALE reply complete the new request's handler with + /// the wrong payload. Nothing downstream can tell the two apart; the token has to. + /// + /// Seeded rather than randomised, because the clock also moves in one direction: the next + /// process starts above the tokens the last one issued unless it issued more of them than + /// milliseconds have passed. Wrapped into the positive range because 0 means "no reply wanted" + /// and a negative token would be a minus sign in a wire path. + private static int nextReplyToken = + (int) (System.currentTimeMillis() & 0x3FFFFFFFL) + 1; /// A request waiting for its answer. private static final class PendingReply { @@ -263,6 +275,11 @@ public static void sendMessage(WearableMessage message, WearableReplyHandler rep if (reply != null) { synchronized (pendingReplies) { token = nextReplyToken++; + if (nextReplyToken <= 0) { + // Wrapped. Back to 1 rather than through 0 and into the negatives: 0 is the + // "no answer wanted" marker every dispatch site tests for. + nextReplyToken = 1; + } pendingReplies.put(Integer.valueOf(token), new PendingReply(reply, message.getPath())); } 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 ebdccbe530e..a5434c819e0 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 @@ -2050,15 +2050,26 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, .append("end\n") .append("def cn1_watch_excludes_watch(condition)\n") .append(" return false unless condition.include?('os(')\n") + // A DISJUNCTION can still be true on the watch through its other operand, so an + // os() test that is only one side of an || proves nothing. A conjunction is safe: + // `os(iOS) && FEATURE` is false on the watch whatever FEATURE is. + .append(" return false if condition.include?('||')\n") .append(" return true if condition =~ /!\\s*os\\(\\s*watchOS\\s*\\)/\n") .append(" return false if condition.include?('os(watchOS)')\n") .append(" condition =~ /os\\(\\s*(iOS|macOS|tvOS|visionOS|Linux|Windows|Android)" + "\\s*\\)/ ? true : false\n") .append("end\n") - /// Positively naming watchOS, which is what lets the other arms be dropped. + /// Positively naming watchOS AND nothing else, which is what lets the other arms be + /// dropped. + /// + /// `os(watchOS) && FEATURE` mentions watchOS and is not therefore true: with FEATURE + /// off Swift compiles the `#else`, and treating the first arm as selected suppressed + /// that else and dropped a package imported only there. Selection is the only + /// direction that can silence another arm, so it takes the whole expression being + /// demonstrably true -- a bare os(watchOS) test and nothing more. .append("def cn1_watch_selects_watch(condition)\n") - .append(" condition.include?('os(watchOS)') && " - + "!(condition =~ /!\\s*os\\(\\s*watchOS\\s*\\)/)\n") + .append(" bare = condition.sub(/\\A#(if|elseif)\\b/, '').strip\n") + .append(" bare =~ /\\Aos\\(\\s*watchOS\\s*\\)\\z/ ? true : false\n") .append("end\n"); // Mirrored only when the WATCH sources actually import the product. 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 6a89ed18a97..218d0d56adf 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 @@ -724,6 +724,14 @@ void swiftPackageProductsReachTheWatchTarget(@TempDir Path tmp) throws Exception assertTrue(ruby.contains("decided << true") && ruby.contains("decided[i]"), "the taken-arm state is what drops the else of a watch-first branch: " + ruby); assertTrue(ruby.contains("cn1_watch_selects_watch"), ruby); + // Selection is the only direction that can silence another arm, so it takes the whole + // expression being demonstrably true: `os(watchOS) && FEATURE` mentions watchOS and is not + // therefore true, and suppressing its #else dropped a package imported only there. + assertTrue(ruby.contains("\\Aos\\(\\s*watchOS\\s*\\)\\z"), + "only a bare watchOS test may close a branch: " + ruby); + // And a disjunction can still be true on the watch through its other operand, so one os() + // test inside an || proves nothing. + assertTrue(ruby.contains("return false if condition.include?('||')"), ruby); // Only a demonstrably watchOS arm closes a branch: marking an unevaluable #elseif as // decided suppressed the #else, which is the arm the watch compiles when the flags are off. assertTrue(ruby.contains("decided[i] = true if cn1_watch_selects_watch(t)"), ruby); From 197a59807492b2d262b2f6b7173b804ea5cb70d5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:58:57 +0700 Subject: [PATCH 235/250] Wearables: a listener has to exist before the spool is skipped Display.isInitialized() was the whole test for "the app can take this now", and it answers a different question. Between initialization and the app's addMessageListener call, a one-shot message handed to the in-memory queue is held by nobody -- and a process killed in that window loses it outright, because the Data Layer does not retain a message and a removal's item is already gone. That is precisely the window the durable spool exists for, and it was being skipped. The bar for skipping it is now someone actually being there: WearableConnection answers hasMessageListener() and hasDataListener(), and a message, a request and a removal each ask about the listener kind that would receive them. Until one is registered the payload is written down, and the drain hands it over as soon as the app registers. Co-Authored-By: Claude Opus 5 (1M context) --- .../wearable/WearableConnection.java | 28 +++++++++++++++++++ .../builders/wearable/CN1WearableBridge.java | 25 +++++++++++++---- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/wearable/WearableConnection.java b/CodenameOne/src/com/codename1/wearable/WearableConnection.java index 1e3a0e57037..1de3501c4e9 100644 --- a/CodenameOne/src/com/codename1/wearable/WearableConnection.java +++ b/CodenameOne/src/com/codename1/wearable/WearableConnection.java @@ -624,6 +624,34 @@ public void run() { /// #### Returns /// /// `true` when a request registered under that token is still outstanding. + /// Framework/port entry point: whether any application code is listening for messages yet. + /// + /// A port with a durable spool asks this before handing a one-shot message to the in-memory + /// queue instead. `Display` being initialized is not the same question: between initialization + /// and the app's `addMessageListener` call the queue holds payloads nothing has received, and a + /// process killed in that window loses a message the Data Layer does not retain either. A port + /// that can write the message down should keep doing so until someone is there to take it. + /// + /// #### Returns + /// + /// true when at least one message listener is registered + public static boolean hasMessageListener() { + synchronized (pendingMessages) { + return !messageListeners.isEmpty(); + } + } + + /// The same question for replicated-data listeners, which receive removals. + /// + /// #### Returns + /// + /// true when at least one data listener is registered + public static boolean hasDataListener() { + synchronized (pendingData) { + return !dataListeners.isEmpty(); + } + } + public static boolean hasPendingReply(int replyToken) { synchronized (pendingReplies) { return pendingReplies.containsKey(Integer.valueOf(replyToken)); 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 e486945f073..102bb50b601 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 @@ -1389,7 +1389,7 @@ static void ensureAppRunning() { /// Hands a one-shot message to a live listener, or writes it down for the next process. static void spoolOrDeliverMessage(Context context, String path, byte[] payload) { - if (deliverableNow(context)) { + if (deliverableNow(context, true)) { WearableConnection.deliverMessage(path, payload, 0); return; } @@ -1421,7 +1421,7 @@ static void spoolOrDeliverMessage(Context context, String path, byte[] payload) /// with it has nothing to cancel. static void spoolOrDeliverRequest(Context context, String path, byte[] payload, int peerToken, String sourceNodeId) { - if (deliverableNow(context)) { + if (deliverableNow(context, true)) { // The peer's token is unique only on the peer, so trade it for a locally unique one // keyed to the node that asked; two watches can otherwise pick the same number. int localToken = rememberRequestOrigin(peerToken, sourceNodeId); @@ -1440,7 +1440,7 @@ static void spoolOrDeliverRequest(Context context, String path, byte[] payload, /// The same for a removal, whose item no longer exists to be replayed from. static void spoolOrDeliverRemoval(Context context, String path) { - if (deliverableNow(context)) { + if (deliverableNow(context, false)) { WearableConnection.deliverDataRemoved(path); return; } @@ -1451,12 +1451,27 @@ static void spoolOrDeliverRemoval(Context context, String path) { drainSpool(context); } - /// Whether this process can run app code AND nothing older is still waiting to be replayed. - private static boolean deliverableNow(Context context) { + /// Whether a LISTENER can take this right now, and nothing older is waiting to be replayed. + /// + /// `Display.isInitialized()` was the whole test, and it answers a different question. Between + /// initialization and the app's addMessageListener call the in-memory queue holds payloads + /// nothing has received -- and a process killed in that window loses a one-shot message the + /// Data Layer does not retain and a removal whose item is already gone. The spool is the thing + /// that survives it, so the bar for skipping the spool is someone actually being there. + /// + /// @param wantsMessageListener true for a message or a request, false for a data removal + private static boolean deliverableNow(Context context, boolean wantsMessageListener) { try { if (!com.codename1.ui.Display.isInitialized()) { return false; } + if (wantsMessageListener) { + if (!WearableConnection.hasMessageListener()) { + return false; + } + } else if (!WearableConnection.hasDataListener()) { + return false; + } } catch (Throwable notInitialized) { return false; } From c9f1cb52ee7a7fdecb59b0c8d577e2afc0aa39e2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:55:42 +0700 Subject: [PATCH 236/250] Wearables: give the packaging notification tests the same 45 minutes The sample declares a watchMain, so its app scheme carries an embedded watch app with a ParparVM translation of its own and xcodebuild builds that before running a single test. I raised this step's budget in scripts-ios-native.yml when it first timed out and missed that ios-packaging.yml runs the very same script -- it timed out there at 20 minutes for exactly the same reason. Nothing else in that job was wrong: its iOS UI smoke step reports pass=48 fail=119 both here and on the previous commit, which passed, so those screenshot diffs are pre-existing on this branch and non-gating rather than something this change caused. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ios-packaging.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ios-packaging.yml b/.github/workflows/ios-packaging.yml index 6e05bb99c6a..8a5accb1d02 100644 --- a/.github/workflows/ios-packaging.yml +++ b/.github/workflows/ios-packaging.yml @@ -237,7 +237,11 @@ jobs: ./scripts/run-ios-native-tests.sh \ "${{ steps.build_ios_app.outputs.workspace }}" \ "${{ steps.build_ios_app.outputs.scheme }}" - timeout-minutes: 20 + # 20 was enough while the sample built one app. It declares a watchMain now, so the app + # scheme carries an embedded watch app with a ParparVM translation of its own and + # xcodebuild builds that before running a single test. The same raise was already applied + # to this step in scripts-ios-native.yml; this workflow runs it too and was missed. + timeout-minutes: 45 - name: Upload packaging artifacts if: always() From 28745cb6c154e11735aaa77e400adfefe026de0b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:51:38 +0700 Subject: [PATCH 237/250] Wearables: a command-line build argument outranks the project file, and tags are elements mirrorSecondaryEntryPointsToBuildArgs runs AFTER overlayCommandLineBuildHints, so a -Dcodename1.arg.watchMain= passed on the command line was already sitting in the properties -- and the mirror overwrote it with the project file's codename1.watchMain. The standard build-argument override silently did nothing on a cloud build: the wrong lifecycle, or a companion where a standalone Wear build was asked for. An existing value wins now; the mirror only carries a project-file setting into the args channel when nothing has put it there already. And the plist scanner matches ELEMENTS rather than literal tag text. `` is the same element as `` to an XML parser -- which is what reads the phone's plist -- so the phone accepted an override the watch never found, and the pair shipped with different versions. Opening and closing tags are both patterns now, and the scan advances past the content rather than by a fixed tag width, since `` is not the length of ``. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/WatchNativeBuilder.java | 86 +++++++++++++++---- .../com/codename1/maven/CN1BuildMojo.java | 18 +++- .../builders/WatchNativeBuilderTest.java | 24 ++++++ .../CN1BuildMojoSecondaryEntryPointTest.java | 34 ++++++++ 4 files changed, 141 insertions(+), 21 deletions(-) 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 a5434c819e0..23864ec3064 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 @@ -764,20 +764,21 @@ static java.util.List injectedPlistKeys(BuildRequest request) { } int at = 0; while (true) { - int open = nextMarkup(inject, "", at); - if (open < 0) { + int content = contentAfterOpenTag(inject, "key", at); + if (content < 0) { return out; } - int close = closeOfElement(inject, open + "".length(), ""); + int close = closeOfElement(inject, content, ""); if (close < 0) { return out; } - String key = plistStringContent( - inject.substring(open + "".length(), close)).trim(); + String key = plistStringContent(inject.substring(content, close)).trim(); if (key.length() > 0 && !out.contains(key)) { out.add(key); } - at = close + "".length(); + // Past the content, not past a fixed-width end tag: `
` is longer than `
` + // and the scan for the next opening tag skips whatever sits between them anyway. + at = close + 1; } } @@ -795,29 +796,27 @@ static String injectedPlistString(BuildRequest request, String key) { // different marketing versions, which archive validation rejects. int at = 0; while (true) { - int open = nextMarkup(inject, "", at); - if (open < 0) { + int content = contentAfterOpenTag(inject, "key", at); + if (content < 0) { return null; } - int close = closeOfElement(inject, open + "".length(), ""); + int close = closeOfElement(inject, content, ""); if (close < 0) { return null; } - at = close + "
".length(); - if (!key.equals(plistStringContent( - inject.substring(open + "".length(), close)).trim())) { + at = close + 1; + if (!key.equals(plistStringContent(inject.substring(content, close)).trim())) { continue; } - int valueOpen = nextMarkup(inject, "", at); - if (valueOpen < 0) { + int valueContent = contentAfterOpenTag(inject, "string", at); + if (valueContent < 0) { return null; } - int valueClose = closeOfString(inject, valueOpen + "".length()); + int valueClose = closeOfString(inject, valueContent); if (valueClose < 0) { return null; } - return plistStringContent( - inject.substring(valueOpen + "".length(), valueClose)); + return plistStringContent(inject.substring(valueContent, valueClose)); } } @@ -837,6 +836,49 @@ private static int closeOfString(String inject, int from) { /// ignores it. A raw indexOf did not: the watch took the commented version while the app it is /// embedded in kept its real one, and archive validation rejects that mismatch. CDATA is the /// same story from the other side: {@code x]]>} is text, not an element. + /// The end of the next real opening tag for an element, or -1. + /// + /// Returns where its CONTENT starts, because an opening tag is not a fixed width: `` is + /// the same element as `` to an XML parser, and the literal search missed it -- the phone + /// accepted the override and the watch fell back to its generated version, which is the + /// mismatch archive validation rejects. + private static int contentAfterOpenTag(String inject, String element, int from) { + java.util.regex.Matcher m = java.util.regex.Pattern + .compile("<" + element + "(?:\\s[^>]*)?>") + .matcher(inject); + int i = from; + while (i <= inject.length() && m.find(i)) { + int at = m.start(); + int skipped = skipMarkupBefore(inject, at, i); + if (skipped == at) { + return m.end(); + } + if (skipped < 0) { + return -1; + } + i = skipped; + } + return -1; + } + + /// Where to resume scanning so that `at` is not inside a comment or a CDATA section. + /// + /// Returns `at` when it is already outside both, the position just past the enclosing + /// construct when it is not, and -1 when that construct never ends. + private static int skipMarkupBefore(String inject, int at, int from) { + int cdata = inject.indexOf(CDATA_OPEN, from); + int comment = inject.indexOf(COMMENT_OPEN, from); + boolean cdataFirst = cdata >= 0 && (comment < 0 || cdata < comment); + int skipFrom = cdataFirst ? cdata : comment; + if (skipFrom < 0 || skipFrom > at) { + return at; + } + String opener = cdataFirst ? CDATA_OPEN : COMMENT_OPEN; + String closer = cdataFirst ? CDATA_CLOSE : COMMENT_CLOSE; + int end = inject.indexOf(closer, skipFrom + opener.length()); + return end < 0 ? -1 : end + closer.length(); + } + private static int nextMarkup(String inject, String tag, int from) { int i = from; while (i <= inject.length()) { @@ -866,12 +908,18 @@ private static int nextMarkup(String inject, String tag, int from) { /// The end tag that closes an element, skipping over CDATA sections and comments. private static int closeOfElement(String inject, int from, String closeTag) { + // `` closes the same element as ``, so the tag is matched as a pattern rather + // than as literal text -- the same reason the opening tags are. + java.util.regex.Matcher m = java.util.regex.Pattern + .compile(java.util.regex.Pattern.quote( + closeTag.substring(0, closeTag.length() - 1)) + "\\s*>") + .matcher(inject); int i = from; while (i <= inject.length()) { - int close = inject.indexOf(closeTag, i); - if (close < 0) { + if (!m.find(i)) { return -1; } + int close = m.start(); int cdata = inject.indexOf(CDATA_OPEN, i); int comment = inject.indexOf(COMMENT_OPEN, i); // Whichever construct starts first, if either starts before the candidate end tag. 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 9eb309418aa..61e3d0d059b 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 @@ -667,9 +667,23 @@ private static void putSecondaryEntryPointArguments(BuildRequest r, Properties p static void mirrorSecondaryEntryPointsToBuildArgs(Properties props) { for (Map.Entry entry : SECONDARY_ENTRY_POINTS.entrySet()) { String value = props.getProperty(entry.getKey()); - if (value != null && value.trim().length() > 0) { - props.setProperty("codename1.arg." + entry.getValue(), value.trim()); + if (value == null || value.trim().length() == 0) { + continue; + } + String argKey = "codename1.arg." + entry.getValue(); + // An existing value WINS. This runs after overlayCommandLineBuildHints, so a + // -Dcodename1.arg.watchMain=... passed on the command line is already sitting here -- + // and overwriting it with the project file's codename1.watchMain made the standard + // build-argument override silently do nothing, handing the cloud the wrong lifecycle or + // a companion where a standalone Wear build was asked for. + // + // The mirror exists to carry a project-file setting into the args channel the daemon + // reads, which is only needed when nothing has put it there already. + String existing = props.getProperty(argKey); + if (existing != null && existing.trim().length() > 0) { + continue; } + props.setProperty(argKey, value.trim()); } } 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 218d0d56adf..d6fb3fae5da 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 @@ -899,6 +899,30 @@ void dragPhasesReachTheWatchPointerPipeline(@TempDir Path tmp) throws Exception "the tap-only gesture is what could not express a drag: " + swift); } + /** + * Whitespace inside a tag does not make it a different element. + * + *

{@code CFBundleVersion} is the same element to an XML parser, which is what + * reads the phone's plist -- so the phone took the override while the watch fell back to its + * generated version, and archive validation rejects that mismatch.

+ */ + @Test + void tagsAreMatchedAsElementsNotAsLiteralText(@TempDir Path tmp) throws IOException { + BuildRequest req = request(); + req.putArgument("watchMain", WATCH_MAIN); + req.putArgument("ios.plistInject", + "CFBundleVersion42"); + + assertEquals("42", WatchNativeBuilder.injectedPlistString(req, "CFBundleVersion"), + "a spaced opening tag names the same key an XML parser sees"); + assertTrue(WatchNativeBuilder.injectedPlistKeys(req).contains("CFBundleVersion"), + "and the key scan has to agree with the lookup"); + + String plist = writeInfoPlist(req, tmp); + assertTrue(plist.contains("CFBundleVersion\n 42"), + "the watch takes the injected version: " + plist); + } + /// The bootstrap has to call the stub that actually exists in the watch binary. @Test void theBootstrapEntersTheWatchStub(@TempDir Path tmp) throws Exception { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildMojoSecondaryEntryPointTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildMojoSecondaryEntryPointTest.java index 03bb06e4d32..c6142759058 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildMojoSecondaryEntryPointTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CN1BuildMojoSecondaryEntryPointTest.java @@ -37,6 +37,40 @@ * namespace or a cloud build produces no watch app at all. */ public class CN1BuildMojoSecondaryEntryPointTest { + /** + * A command-line override survives the mirror. + * + *

This runs after overlayCommandLineBuildHints, so a {@code -Dcodename1.arg.watchMain=...} + * is already in the properties -- and overwriting it with the project file's value made the + * standard build-argument override silently do nothing, sending the cloud the wrong lifecycle + * or a companion where a standalone Wear build was asked for.

+ */ + @Test + public void aCommandLineArgumentIsNotOverwrittenByTheProjectFile() { + Properties props = new Properties(); + props.setProperty("codename1.watchMain", "com.acme.FromProjectFile"); + props.setProperty("codename1.arg.watchMain", "com.acme.FromCommandLine"); + props.setProperty("codename1.watchStandalone", "false"); + props.setProperty("codename1.arg.watchStandalone", "true"); + + CN1BuildMojo.mirrorSecondaryEntryPointsToBuildArgs(props); + + assertEquals("com.acme.FromCommandLine", props.getProperty("codename1.arg.watchMain")); + assertEquals("true", props.getProperty("codename1.arg.watchStandalone")); + } + + /** A blank argument is not an override, so the project file still gets mirrored. */ + @Test + public void aBlankArgumentDoesNotSuppressTheMirror() { + Properties props = new Properties(); + props.setProperty("codename1.watchMain", "com.acme.FromProjectFile"); + props.setProperty("codename1.arg.watchMain", " "); + + CN1BuildMojo.mirrorSecondaryEntryPointsToBuildArgs(props); + + assertEquals("com.acme.FromProjectFile", props.getProperty("codename1.arg.watchMain")); + } + @Test public void watchMainReachesTheBuildServer() { From 9212138f1be26a4bf74b4272e0f32e25e3eb6fd6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:00:37 +0700 Subject: [PATCH 238/250] Wearables: recognise the Objective-C platform guards, and keep commit() off the EDT `#if !TARGET_OS_WATCH` around a phone-only @import is the standard Objective-C spelling, and the conditional scanner only understood Swift's os() expressions -- so it read the guard as unevaluable, kept the import, and attached an iOS-only package to the watch target over code the compiler excludes. TargetConditionals are read now, with TARGET_OS_IPHONE deliberately absent from the excluding list: it is 1 on watchOS, so a block guarded by it does compile there. `#ifdef TARGET_OS_WATCH` is not a platform test either -- every one of those macros is always defined, as 0 or 1 -- so it stays unevaluable. And the durable transfer claim no longer writes from the EDT. The delivery callback runs there once the listener has had the payload, and commit() is a synchronous disk write, so a slow or contended store froze rendering and input for the duration of every received transfer. It moves to the same single-threaded timer the rest of the transfer bookkeeping uses -- claims still land in confirmation order -- and the acknowledgement moves with it, staying behind the write rather than reopening the window that ordering exists to close. Nine conditional shapes checked, including the Swift ones from earlier rounds. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/WatchNativeBuilder.java | 21 +++++- .../builders/wearable/CN1WearableBridge.java | 71 +++++++++++-------- .../builders/WatchNativeBuilderTest.java | 9 +++ 3 files changed, 72 insertions(+), 29 deletions(-) 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 23864ec3064..0017d1dde3f 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 @@ -2097,6 +2097,21 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, .append(" out.join\n") .append("end\n") .append("def cn1_watch_excludes_watch(condition)\n") + // Objective-C guards its platforms with TargetConditionals macros, not Swift's + // os() expressions, and `#if !TARGET_OS_WATCH` around a phone-only @import is the + // standard spelling. Treating it as unevaluable kept the import, which attached an + // iOS-only package to the watch target over code the compiler excludes. + // + // TARGET_OS_IPHONE is deliberately NOT in the excluding list: it is 1 on watchOS, + // so a block guarded by it does compile there. TARGET_OS_IOS, _OSX, _TV, + // _MACCATALYST and _VISION are each 0 on the watch. + .append(" unless condition.include?('||')\n") + .append(" return true if condition =~ /!\\s*TARGET_OS_WATCH\\b/\n") + .append(" unless condition =~ /\\bTARGET_OS_WATCH\\b/\n") + .append(" return true if condition =~ /\\bTARGET_OS_" + + "(IOS|OSX|TV|MACCATALYST|VISION)\\b/\n") + .append(" end\n") + .append(" end\n") .append(" return false unless condition.include?('os(')\n") // A DISJUNCTION can still be true on the watch through its other operand, so an // os() test that is only one side of an || proves nothing. A conjunction is safe: @@ -2117,7 +2132,11 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, /// demonstrably true -- a bare os(watchOS) test and nothing more. .append("def cn1_watch_selects_watch(condition)\n") .append(" bare = condition.sub(/\\A#(if|elseif)\\b/, '').strip\n") - .append(" bare =~ /\\Aos\\(\\s*watchOS\\s*\\)\\z/ ? true : false\n") + .append(" return true if bare =~ /\\Aos\\(\\s*watchOS\\s*\\)\\z/\n") + // The Objective-C spelling of the same thing. `#ifdef TARGET_OS_WATCH` is NOT it: + // TargetConditionals defines every one of those macros as 0 or 1, so the block is + // always compiled and the test says nothing about the platform. + .append(" bare =~ /\\ATARGET_OS_WATCH\\z/ ? true : false\n") .append("end\n"); // Mirrored only when the WATCH sources actually import the product. 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 102bb50b601..6d67c280aef 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 @@ -4244,34 +4244,49 @@ static void confirmTransferDelivered(Context context, Uri uri, long sequence, if (uri == null || !reachedListener) { return; } - String key = uri.getHost() + ":" + uri.getPath(); - boolean durable; - synchronized (transferClaims) { - // The persisted form carries a RECEIPT TIME that the in-memory form does not need. The - // stamp is a Lamport sequence, and observeSequence deliberately drags that ahead of - // wall time whenever a peer's clock is ahead -- so comparing it against a wall-clock - // cutoff would keep such a claim until real time caught up with a fabricated future, - // and since every transfer gets a unique sequence-suffixed URI the store would grow - // without bound. Pruning needs a clock that measures elapsed time; the sequence is not - // one. - durable = persistClaim(context, key, sequence + "|" - + (uri.getHost() == null ? "" : uri.getHost()) - + "|" + System.currentTimeMillis()); - } - if (!durable) { - // No acknowledgement without a durable claim. The acknowledgement is what lets the - // SENDER retire the item, and retiring it while this device has no claim on disk means - // a restart here finds neither the claim nor -- eventually -- the item, so the one-shot - // transfer is simply lost. Staying silent costs the sender an item kept until the hard - // cap and buys a redelivery this device will accept, which is the recoverable side of - // the trade. commit() returning false is rare (a full or unwritable store) and is - // exactly when this matters. - android.util.Log.w("CN1Wearable", "transfer claim for " + key - + " was not written; withholding the acknowledgement so the sender keeps the" - + " item and can redeliver it"); - return; - } - publishTransferAck(context, uri); + final String key = uri.getHost() + ":" + uri.getPath(); + // The persisted form carries a RECEIPT TIME that the in-memory form does not need. The + // stamp is a Lamport sequence, and observeSequence deliberately drags that ahead of wall + // time whenever a peer's clock is ahead -- so comparing it against a wall-clock cutoff + // would keep such a claim until real time caught up with a fabricated future, and since + // every transfer gets a unique sequence-suffixed URI the store would grow without bound. + // Pruning needs a clock that measures elapsed time; the sequence is not one. + final String stamp = sequence + "|" + + (uri.getHost() == null ? "" : uri.getHost()) + + "|" + System.currentTimeMillis(); + final Context c = context; + final Uri item = uri; + // OFF the calling thread. This runs from the delivery callback, which the tracked-data path + // invokes on the EDT once the listener has had the payload -- and commit() is a synchronous + // disk write, so a slow or contended store froze rendering and input for the duration of + // every received transfer. The timer is the same single-threaded worker the rest of the + // transfer bookkeeping uses, so claims still land in the order they were confirmed. + // + // The acknowledgement moves with it and stays behind the write: it is what lets the SENDER + // retire the item, and publishing it from here while the claim was still queued would put + // it back in the window this ordering exists to close. + transferTimer.schedule(new java.util.TimerTask() { + public void run() { + boolean durable; + synchronized (transferClaims) { + durable = persistClaim(c, key, stamp); + } + if (!durable) { + // No acknowledgement without a durable claim. The acknowledgement is what lets + // the SENDER retire the item, and retiring it while this device has no claim on + // disk means a restart here finds neither the claim nor -- eventually -- the + // item, so the one-shot transfer is simply lost. Staying silent costs the + // sender an item kept until the hard cap and buys a redelivery this device will + // accept, which is the recoverable side of the trade. commit() returning false + // is rare (a full or unwritable store) and is exactly when this matters. + android.util.Log.w("CN1Wearable", "transfer claim for " + key + + " was not written; withholding the acknowledgement so the sender" + + " keeps the item and can redeliver it"); + return; + } + publishTransferAck(c, item); + } + }, 0); } /// Tells the SENDER that this device has handed the transfer to a listener. 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 d6fb3fae5da..2b372dc3dc7 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 @@ -732,6 +732,15 @@ void swiftPackageProductsReachTheWatchTarget(@TempDir Path tmp) throws Exception // And a disjunction can still be true on the watch through its other operand, so one os() // test inside an || proves nothing. assertTrue(ruby.contains("return false if condition.include?('||')"), ruby); + // Objective-C guards its platforms with TargetConditionals, not Swift os() expressions, and + // `#if !TARGET_OS_WATCH` around a phone-only @import is the standard spelling. + assertTrue(ruby.contains("TARGET_OS_WATCH"), ruby); + assertTrue(ruby.contains("TARGET_OS_\n" ) || ruby.contains("(IOS|OSX|TV|MACCATALYST|VISION)"), + "the excluding macros have to be named: " + ruby); + // TARGET_OS_IPHONE is 1 on watchOS, so a block guarded by it DOES compile there and must + // not be treated as excluding. + assertFalse(ruby.contains("IPHONE"), + "TARGET_OS_IPHONE is true on the watch and cannot exclude it: " + ruby); // Only a demonstrably watchOS arm closes a branch: marking an unevaluable #elseif as // decided suppressed the #else, which is the arm the watch compiles when the flags are off. assertTrue(ruby.contains("decided[i] = true if cn1_watch_selects_watch(t)"), ruby); From 1433739facef6c33c129b644bcb4c30a6b3f8c92 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:23:50 +0700 Subject: [PATCH 239/250] Wearables: attempts are charged only to a delivery someone can take, and negation is read A spooled record was claimed and charged an attempt even when no listener of its kind existed. Replaying it then only parked the callback in memory, so the confirmation never ran -- and a service process reclaimed over and over charged an attempt per lifetime for a delivery that never reached application code, deleting it on the budget alone after four of them. That is precisely the loss the budget exists to bound. A record nobody can take is now left exactly as it was, for the launch where someone can. `#elif` is the C spelling of `#elseif`, and reading only the Swift form left a suppressed first arm suppressed straight through the watch arm of an Objective-C `#if TARGET_OS_IOS ... #elif TARGET_OS_WATCH ... #endif`. And a NEGATED platform test is the opposite answer. `!TARGET_OS_IOS` is true on watchOS, so that arm is the one the watch compiles -- excluding it merely because the condition mentions TARGET_OS_IOS removed the active arm and dropped the package imported there. The same rule was wrong on the Swift side for `!os(iOS)` and is fixed with it. Ten shapes checked across both spellings. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/WatchNativeBuilder.java | 49 ++++++++++++------- .../builders/wearable/CN1WearableBridge.java | 25 ++++++++++ .../builders/WatchNativeBuilderTest.java | 8 ++- 3 files changed, 63 insertions(+), 19 deletions(-) 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 0017d1dde3f..aa656cb3c7c 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 @@ -2072,7 +2072,12 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, .append(" suppressed << false; decided << false\n") .append(" end\n") .append(" next\n") - .append(" elsif t.start_with?('#elseif') || t.start_with?('#else')\n") + // `#elif` is the C spelling and `#elseif` the Swift one. Reading only the Swift + // form left a suppressed first arm suppressed straight through the watch arm of an + // Objective-C `#if TARGET_OS_IOS ... #elif TARGET_OS_WATCH ... #endif`, so a + // package imported only there was classified unused. + .append(" elsif t.start_with?('#elseif') || t.start_with?('#elif') " + + "|| t.start_with?('#else')\n") .append(" next if suppressed.empty?\n") .append(" i = suppressed.length - 1\n") .append(" if decided[i]\n") @@ -2097,29 +2102,37 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, .append(" out.join\n") .append("end\n") .append("def cn1_watch_excludes_watch(condition)\n") + // Whitespace after a `!` is legal and would otherwise hide the negation. + .append(" c = condition.gsub(/!\\s+/, '!')\n") + // A DISJUNCTION can still be true on the watch through its other operand, so an + // os() or TARGET_OS_ test that is only one side of an || proves nothing. A + // conjunction is safe: `os(iOS) && FEATURE` is false on the watch whatever FEATURE + // is. + .append(" return false if c.include?('||')\n") // Objective-C guards its platforms with TargetConditionals macros, not Swift's // os() expressions, and `#if !TARGET_OS_WATCH` around a phone-only @import is the // standard spelling. Treating it as unevaluable kept the import, which attached an // iOS-only package to the watch target over code the compiler excludes. + .append(" return true if c =~ /!TARGET_OS_WATCH\\b/\n") + .append(" unless c =~ /\\bTARGET_OS_WATCH\\b/\n") + // NEGATED is the opposite answer. `!TARGET_OS_IOS` is TRUE on watchOS, so that arm + // is the one the watch compiles -- suppressing it dropped a package imported only + // there. Only a POSITIVE test for another platform excludes the watch. // - // TARGET_OS_IPHONE is deliberately NOT in the excluding list: it is 1 on watchOS, - // so a block guarded by it does compile there. TARGET_OS_IOS, _OSX, _TV, - // _MACCATALYST and _VISION are each 0 on the watch. - .append(" unless condition.include?('||')\n") - .append(" return true if condition =~ /!\\s*TARGET_OS_WATCH\\b/\n") - .append(" unless condition =~ /\\bTARGET_OS_WATCH\\b/\n") - .append(" return true if condition =~ /\\bTARGET_OS_" + // TARGET_OS_IPHONE is absent from the list on purpose: it is 1 on watchOS, so a + // block guarded by it does compile there. + .append(" unless c =~ /!TARGET_OS_(IOS|OSX|TV|MACCATALYST|VISION)\\b/\n") + .append(" return true if c =~ /\\bTARGET_OS_" + "(IOS|OSX|TV|MACCATALYST|VISION)\\b/\n") .append(" end\n") .append(" end\n") - .append(" return false unless condition.include?('os(')\n") - // A DISJUNCTION can still be true on the watch through its other operand, so an - // os() test that is only one side of an || proves nothing. A conjunction is safe: - // `os(iOS) && FEATURE` is false on the watch whatever FEATURE is. - .append(" return false if condition.include?('||')\n") - .append(" return true if condition =~ /!\\s*os\\(\\s*watchOS\\s*\\)/\n") - .append(" return false if condition.include?('os(watchOS)')\n") - .append(" condition =~ /os\\(\\s*(iOS|macOS|tvOS|visionOS|Linux|Windows|Android)" + .append(" return false unless c.include?('os(')\n") + .append(" return true if c =~ /!\\s*os\\(\\s*watchOS\\s*\\)/\n") + .append(" return false if c.include?('os(watchOS)')\n") + // The same negation rule on the Swift side: `!os(iOS)` is true on the watch. + .append(" return false if c =~ /!os\\(\\s*" + + "(iOS|macOS|tvOS|visionOS|Linux|Windows|Android)\\s*\\)/\n") + .append(" c =~ /os\\(\\s*(iOS|macOS|tvOS|visionOS|Linux|Windows|Android)" + "\\s*\\)/ ? true : false\n") .append("end\n") /// Positively naming watchOS AND nothing else, which is what lets the other arms be @@ -2129,9 +2142,9 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, /// off Swift compiles the `#else`, and treating the first arm as selected suppressed /// that else and dropped a package imported only there. Selection is the only /// direction that can silence another arm, so it takes the whole expression being - /// demonstrably true -- a bare os(watchOS) test and nothing more. + /// demonstrably true -- a bare watchOS test and nothing more. .append("def cn1_watch_selects_watch(condition)\n") - .append(" bare = condition.sub(/\\A#(if|elseif)\\b/, '').strip\n") + .append(" bare = condition.sub(/\\A#(if|elseif|elif)\\b/, '').strip\n") .append(" return true if bare =~ /\\Aos\\(\\s*watchOS\\s*\\)\\z/\n") // The Objective-C spelling of the same thing. `#ifdef TARGET_OS_WATCH` is NOT it: // TargetConditionals defines every one of those macros as 0 or 1, so the block is 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 6d67c280aef..58b36c4088c 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 @@ -1680,6 +1680,18 @@ private static String claimSpooled(Context context, String key) { SPOOL_IN_FLIGHT.remove(key); return null; } + // Before the attempt is charged. A record replayed with no listener registered is + // only parked in memory, so its confirmation callback never runs -- and a service + // process reclaimed over and over each charged another attempt for a delivery that + // never reached application code. Four of those lifetimes deleted the record on the + // attempt budget alone, which is precisely the loss the budget was meant to bound. + // + // A record nobody can take is left exactly as it was, for the launch where someone + // can. + if (!listenerExistsFor(bodyOf(record))) { + SPOOL_IN_FLIGHT.remove(key); + return null; + } int attempts = attemptsOf(record) + 1; android.content.SharedPreferences.Editor edit = prefs.edit(); if (attempts > SPOOL_MAX_ATTEMPTS) { @@ -1726,6 +1738,19 @@ private static void releaseSpooled(Context context, String key) { } } + /// Whether a listener of the kind this record needs is registered right now. + private static boolean listenerExistsFor(String body) { + try { + int bar = body.indexOf('|'); + String kind = bar < 0 ? body : body.substring(0, bar); + return SPOOL_REMOVAL.equals(kind) + ? WearableConnection.hasDataListener() + : WearableConnection.hasMessageListener(); + } catch (Throwable unavailable) { + return false; + } + } + /// The leading attempt count of a stored record, or 0 for one written before it had one. private static int attemptsOf(String record) { int bar = record.indexOf('|'); 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 2b372dc3dc7..aee62a296a5 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 @@ -731,12 +731,18 @@ void swiftPackageProductsReachTheWatchTarget(@TempDir Path tmp) throws Exception "only a bare watchOS test may close a branch: " + ruby); // And a disjunction can still be true on the watch through its other operand, so one os() // test inside an || proves nothing. - assertTrue(ruby.contains("return false if condition.include?('||')"), ruby); + assertTrue(ruby.contains("return false if c.include?('||')"), ruby); // Objective-C guards its platforms with TargetConditionals, not Swift os() expressions, and // `#if !TARGET_OS_WATCH` around a phone-only @import is the standard spelling. assertTrue(ruby.contains("TARGET_OS_WATCH"), ruby); assertTrue(ruby.contains("TARGET_OS_\n" ) || ruby.contains("(IOS|OSX|TV|MACCATALYST|VISION)"), "the excluding macros have to be named: " + ruby); + // A NEGATED platform test is the opposite answer: `!TARGET_OS_IOS` is true on the watch, + // so that arm is the one it compiles and suppressing it dropped a package. + assertTrue(ruby.contains("!TARGET_OS_(IOS|OSX|TV|MACCATALYST|VISION)"), ruby); + // And `#elif` is the C spelling of `#elseif`; reading only the Swift form left an + // Objective-C watch arm suppressed behind an excluded first arm. + assertTrue(ruby.contains("t.start_with?('#elif')"), ruby); // TARGET_OS_IPHONE is 1 on watchOS, so a block guarded by it DOES compile there and must // not be treated as excluding. assertFalse(ruby.contains("IPHONE"), From 6f3b637ca4e419e1df18c7b038e5f86ff50656ee Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:54:12 +0700 Subject: [PATCH 240/250] Wearables: reply tokens come from a reserved block, and staged names are quoted The clock seed was not enough on its own, as the review points out: twenty requests and a restart ten milliseconds later reissues half of them, and a delayed reply then completes the NEW process's handler with the old payload. Tokens now come from a block reserved in Preferences, and the block after it is recorded BEFORE any of it is handed out -- so a process that dies mid-run has still moved the stored base past everything it could possibly have issued. Exact rather than probabilistic, for one small write per process. With no storage at all it falls back to the clock, which is a weaker discriminator than the counter and a better one than starting from 1 again. And the staged watch sources are emitted as quoted strings rather than a %w[] word list. A translated native source is named after the class it came from, so a project with a space in one -- `My Bridge.m` -- had that name split into two words: the watch target referenced two files that do not exist and linked without the symbols the real one carries. The excluded-sources list keeps %w[] and says why -- it is a constant in this file that no project-supplied name reaches. Co-Authored-By: Claude Opus 5 (1M context) --- .../wearable/WearableConnection.java | 66 ++++++++++++++----- .../builders/WatchNativeBuilder.java | 12 +++- .../builders/WatchNativeBuilderTest.java | 6 ++ 3 files changed, 65 insertions(+), 19 deletions(-) diff --git a/CodenameOne/src/com/codename1/wearable/WearableConnection.java b/CodenameOne/src/com/codename1/wearable/WearableConnection.java index 1de3501c4e9..02d113d71dc 100644 --- a/CodenameOne/src/com/codename1/wearable/WearableConnection.java +++ b/CodenameOne/src/com/codename1/wearable/WearableConnection.java @@ -87,19 +87,53 @@ public final class WearableConnection { /// alongside the handler so the reply decodes onto a real path -- a payload has to have one. private static final Map pendingReplies = new HashMap(); - /// Reply tokens, seeded from the clock so a restart cannot reissue the previous run's. + /// Where this process's block of reply tokens ends. /// - /// This counted from 1 in every process, and the Android wire path carries nothing but the - /// integer -- so a sender killed with request 1 outstanding, restarted, issuing another request - /// before the peer answered, could have the STALE reply complete the new request's handler with - /// the wrong payload. Nothing downstream can tell the two apart; the token has to. + /// Tokens are handed out from a RESERVED BLOCK, and the next block is recorded before any of it + /// is used. Each run therefore issues numbers no other run can, which is what the wire needs: + /// the Android path carries nothing but the integer, so a sender killed with a request + /// outstanding and restarted could otherwise have the stale reply complete a NEW request's + /// handler with the wrong payload. /// - /// Seeded rather than randomised, because the clock also moves in one direction: the next - /// process starts above the tokens the last one issued unless it issued more of them than - /// milliseconds have passed. Wrapped into the positive range because 0 means "no reply wanted" - /// and a negative token would be a minus sign in a wire path. - private static int nextReplyToken = - (int) (System.currentTimeMillis() & 0x3FFFFFFFL) + 1; + /// A clock seed was the first attempt and is not enough on its own -- twenty requests and a + /// restart ten milliseconds later reissues half of them. The reservation is exact instead of + /// probabilistic, and it costs one small preference write per process. + private static final int REPLY_TOKEN_BLOCK = 4096; + + private static final String REPLY_TOKEN_BASE_KEY = "cn1$wearableReplyTokenBase"; + + private static int nextReplyToken; + + private static int replyTokenLimit; + + /// Reserves the next block, recording where the one after it starts before handing any out. + /// + /// Called with the pendingReplies monitor held. Recording FIRST is the whole point: a process + /// that dies mid-run has still moved the stored base past everything it could have issued. + /// + /// Wraps back to 1 rather than through 0: 0 is the "no answer wanted" marker every dispatch + /// site tests for, and a negative token would put a minus sign in a wire path. + private static void reserveReplyTokenBlock() { + int base; + try { + base = com.codename1.io.Preferences.get(REPLY_TOKEN_BASE_KEY, 0); + } catch (RuntimeException storageUnavailable) { + // No storage is not a reason to hand out a colliding token. The clock is a weaker + // discriminator than the counter and a better one than starting from 1 again. + base = (int) (System.currentTimeMillis() & 0x3FFFFFFFL); + } + if (base <= 0 || base > Integer.MAX_VALUE - REPLY_TOKEN_BLOCK * 2) { + base = 0; + } + nextReplyToken = base + 1; + replyTokenLimit = base + REPLY_TOKEN_BLOCK; + try { + com.codename1.io.Preferences.set(REPLY_TOKEN_BASE_KEY, replyTokenLimit); + } catch (RuntimeException storageUnavailable) { + // The block is still unique within this process; only the cross-restart guarantee is + // lost, and the next run falls back to the clock above. + } + } /// A request waiting for its answer. private static final class PendingReply { @@ -274,12 +308,12 @@ public static void sendMessage(WearableMessage message, WearableReplyHandler rep int token = 0; if (reply != null) { synchronized (pendingReplies) { - token = nextReplyToken++; - if (nextReplyToken <= 0) { - // Wrapped. Back to 1 rather than through 0 and into the negatives: 0 is the - // "no answer wanted" marker every dispatch site tests for. - nextReplyToken = 1; + if (nextReplyToken <= 0 || nextReplyToken > replyTokenLimit) { + // First request of the run, or this block is spent. Either way the next block + // is reserved and recorded before a number out of it is used. + reserveReplyTokenBlock(); } + token = nextReplyToken++; pendingReplies.put(Integer.valueOf(token), new PendingReply(reply, message.getPath())); } 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 aa656cb3c7c..c9354eb156a 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 @@ -1578,18 +1578,24 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // Compile the shared ParparVM sources for the watch, minus the // GL/Metal-only files. Reuse the app target's compile sources so // we track exactly what was generated. + // A %w[] word list is safe here and only here: EXCLUDED_WATCH_SOURCES is a fixed + // constant in this file, so no project-supplied name can carry a space into it. .append("excluded = %w[").append(excluded).append("]\n"); if (!watchSources.isEmpty()) { // The watch compiles its OWN translation, rooted at watchMain and shaken down to what // that entry point reaches. Nothing of the phone's tree is added: sharing it is what // made the watch binary carry the phone's whole graph, and the phone Stub's main then // had to be defined away to stop the two entry points colliding. + // Quoted strings, not a %w[] word list. A translated native source is named after the + // class it came from, and a project with a space in one -- `My Bridge.m` -- had that + // name split into two words, so the watch target referenced two files that do not + // exist and linked without the symbols the real one carries. StringBuilder names = new StringBuilder(); for (String name : watchSources) { if (names.length() > 0) { - names.append(' '); + names.append(", "); } - names.append(name); + names.append('\'').append(IPhoneBuilder.escapeRubyStr(name)).append('\''); } // The app target's file SET, with the watch translation's CONTENTS. // @@ -1599,7 +1605,7 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // always taken its file list from the app target, and that list is what belongs on the // watch too; only the translated bodies differ. So walk the app target exactly as the // shared path does, and swap each file for its watch-src counterpart where one exists. - s.append("watch_sources = %w[").append(names).append("]\n") + s.append("watch_sources = [").append(names).append("]\n") // Relative to the PROJECT, not to the app's -src folder. Naming only // "watch-src" pointed every reference at a directory that does not exist -- // and because most translated files share a basename with the phone's, Xcode 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 aee62a296a5..855e1ec22e7 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 @@ -652,6 +652,12 @@ void aDistinctWatchMainCompilesItsOwnTranslation(@TempDir Path tmp) throws Excep // omit the watch lifecycle class, which the phone never reaches and its translation // therefore shakes out -- the link then fails on that class's symbols. assertTrue(ruby.contains("watch_sources.each"), ruby); + // Quoted strings, not a %w[] word list: a translated source named after a class with a + // space in it -- `My Bridge.m` -- was split into two words, so the target referenced two + // files that do not exist and linked without the symbols the real one carries. + assertTrue(ruby.contains("watch_sources = ['"), + "the staged names have to be quoted: " + ruby); + assertFalse(ruby.contains("watch_sources = %w["), ruby); assertTrue(ruby.contains("watch_group_path + '/' + name"), ruby); } From 35024bb22913218afdc99c846b4e7c0de0e4395b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:10:43 +0700 Subject: [PATCH 241/250] Wearables: an asset catalog is judged by what is in it Dropping every .xcassets was too broad. A project that keeps its images in a custom catalog had none of them on the watch, so a UIImage(named:) from watch-reachable code returned nil at runtime with nothing in the build to explain it. Only a catalog carrying an app icon or a launch image is iOS-specific -- those are the sets with no watch-applicable content, and the reason the filter existed -- while a catalog of ordinary image and colour sets compiles for watchOS like any other. A catalog whose path cannot be read keeps the old conservative answer: a missing asset is a runtime nil, but an uninspectable catalog might be the app's own icon set, and that is a build error for everybody. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/WatchNativeBuilder.java | 26 ++++++++++++++++++- .../builders/WatchNativeBuilderTest.java | 7 +++++ 2 files changed, 32 insertions(+), 1 deletion(-) 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 c9354eb156a..fd71a58a13f 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 @@ -2310,11 +2310,35 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // watchOS widget-extension target, where there is a real archive to verify // it against; until then the developer guide tells the reader to add an // AppIcon set to the watch target before submitting. - s.append("res_skip = %w[.xcassets .storyboard .xib]\n") + // An asset catalog is judged by what is IN it, not by its extension. + // + // Dropping every .xcassets was too broad: a project keeping its images in a custom catalog + // had none of them on the watch, so UIImage(named:) from watch-reachable code returned nil + // at runtime with nothing in the build to say why. Only a catalog carrying an app icon or a + // launch image is iOS-specific -- those are the sets with no watch-applicable content -- + // and a catalog holding ordinary image and colour sets compiles for watchOS like any other. + s.append("def cn1_watch_catalog_is_ios_only(ref)\n") + .append(" path = (ref.real_path.to_s rescue nil)\n") + // Unreadable: keep the old conservative answer. A missing asset is a runtime nil, + // but a catalog that cannot be inspected might be the app's own icon set, and that + // is a build error for everybody. + .append(" return true unless path && File.directory?(path)\n") + .append(" !Dir.glob(File.join(path, '**', '*.{appiconset,launchimage}')).empty?\n") + .append("end\n"); + + s.append("res_skip = %w[.storyboard .xib]\n") .append("app_target.resources_build_phase.files.to_a.each do |bf|\n") .append(" ref = bf.file_ref\n") .append(" next unless ref && ref.path\n") .append(" next if res_skip.any? { |ext| ref.path.to_s.end_with?(ext) }\n") + .append(" if ref.path.to_s.end_with?('.xcassets')\n") + .append(" if cn1_watch_catalog_is_ios_only(ref)\n") + .append(" puts \"[watchNative] not copying #{File.basename(ref.path.to_s)} " + + "into the watch target: it carries an app icon or launch image, which " + + "has no watch-applicable content\"\n") + .append(" next\n") + .append(" end\n") + .append(" end\n") .append(" unless watch_target.resources_build_phase.files_references.include?(ref)\n") .append(" watch_target.resources_build_phase.add_file_reference(ref)\n") .append(" end\n") 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 855e1ec22e7..7f9a5bc6a2b 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 @@ -658,6 +658,13 @@ void aDistinctWatchMainCompilesItsOwnTranslation(@TempDir Path tmp) throws Excep assertTrue(ruby.contains("watch_sources = ['"), "the staged names have to be quoted: " + ruby); assertFalse(ruby.contains("watch_sources = %w["), ruby); + // An asset catalog is judged by what is in it. Dropping every .xcassets left a project + // that keeps its images in a custom catalog with none of them on the watch, so + // UIImage(named:) returned nil at runtime with nothing in the build to say why. + assertTrue(ruby.contains("cn1_watch_catalog_is_ios_only"), ruby); + assertTrue(ruby.contains("appiconset,launchimage"), ruby); + assertFalse(ruby.contains("res_skip = %w[.xcassets"), + "the blanket catalog filter is what dropped the usable ones: " + ruby); assertTrue(ruby.contains("watch_group_path + '/' + name"), ruby); } From e147610106fc6b7922df8d0f27bb31dff871ec76 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:30:51 +0700 Subject: [PATCH 242/250] Wearables: publish off the caller's thread, and pick up master's new module The BuildDaemon build failed with "Could not find the selected project in the reactor: cn1-hardening". Its CI builds the COMPANION CodenameOne branch when one exists, so it was building this branch -- which was ten commits behind master and did not yet have that module. Merging master brings it in; nothing here needed changing for it. putData and transferFile allocated their sequence on the calling thread, and allocating one advances the durable clock floor with a synchronous commit(). Both are called from application code that is usually the EDT, so an ordinary UI-driven publish blocked rendering and input on storage I/O. The whole publication moves to the transfer worker, which preserves the two things that matter: the commit still completes before the item is published, and the worker is single-threaded, so two publications keep the order their callers made them in -- which is exactly what their sequence stamps are meant to record. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/wearable/CN1WearableBridge.java | 53 ++++++++++++++----- 1 file changed, 41 insertions(+), 12 deletions(-) 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 58b36c4088c..0d1641b3c4c 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 @@ -1194,16 +1194,31 @@ static void cancelReplyTimeout(int replyToken) { // --- replicated data ---------------------------------------------------- public void putData(String path, byte[] payload) { - // The payload travels inside a DataMap rather than as the item's raw data so it can be - // stamped with a publication sequence. Both halves of a pair may publish the same logical - // path, which the Data Layer stores as two items under two node authorities; without an - // ordering stamp a reader has no way to tell which of them is the newer value. - PutDataMapRequest req = PutDataMapRequest.create(dataPath(path)); - req.getDataMap().putByteArray(PAYLOAD_KEY, payload == null ? new byte[0] : payload); - req.getDataMap().putLong(SEQUENCE_KEY, nextSequence()); - // Urgent: without it the system may sit on the change for minutes, which reads as "my watch - // never updated" even though the API did its job. - dataClient.putDataItem(req.asPutDataRequest().setUrgent()); + final String p = path; + final byte[] body = payload == null ? new byte[0] : payload; + // OFF the caller's thread, because the caller is usually the EDT. + // + // Allocating the sequence advances the logical-clock floor, and the floor is written with a + // synchronous commit() -- so an ordinary UI-triggered putData blocked rendering and input on + // storage I/O for as long as that took. The whole publication moves to the transfer worker, + // which keeps the two things that matter: the commit still completes before the item is + // published, and the worker is single-threaded, so two publications keep the order their + // callers made them in -- which is what their sequence stamps are supposed to record. + transferTimer.schedule(new java.util.TimerTask() { + public void run() { + // The payload travels inside a DataMap rather than as the item's raw data so it can + // be stamped with a publication sequence. Both halves of a pair may publish the + // same logical path, which the Data Layer stores as two items under two node + // authorities; without an ordering stamp a reader has no way to tell which of them + // is the newer value. + PutDataMapRequest req = PutDataMapRequest.create(dataPath(p)); + req.getDataMap().putByteArray(PAYLOAD_KEY, body); + req.getDataMap().putLong(SEQUENCE_KEY, nextSequence()); + // Urgent: without it the system may sit on the change for minutes, which reads as + // "my watch never updated" even though the API did its job. + dataClient.putDataItem(req.asPutDataRequest().setUrgent()); + } + }, 0); } /** @@ -2332,12 +2347,26 @@ private String[] enumerateDataPathsBlocking() { } public void transferFile(String path, String name, byte[] contents) { + final String p = path; + final String fileName = name == null ? "file" : name; + final byte[] body = contents == null ? new byte[0] : contents; + // Off the caller's thread for the same reason putData is: allocating the sequence advances + // the durable clock floor with a synchronous commit(), and this is called from application + // code that is usually on the EDT. The transfer worker is single-threaded, so transfers + // keep the order they were requested in. + transferTimer.schedule(new java.util.TimerTask() { + public void run() { + publishTransfer(p, fileName, body); + } + }, 0); + } + + /// Builds and publishes one transfer item, on the transfer worker. + private void publishTransfer(String path, String fileName, byte[] body) { // A DataItem's inline payload is capped at about 100KB, which a real file routinely // exceeds; an Asset is the Data Layer's own answer for bulk and is streamed in the // background. The DataItem carries the name and the Asset, so the receiver still gets a // WearableMessage rather than raw bytes. - String fileName = name == null ? "file" : name; - byte[] body = contents == null ? new byte[0] : contents; long sequence = nextSequence(); PutDataMapRequest req = PutDataMapRequest.create(transferPath(path, fileName, sequence)); req.getDataMap().putString("name", fileName); From a01d387b07b649d3325c3cedc8cb358e5e0920ab Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:05:43 +0700 Subject: [PATCH 243/250] Wearables: snapshot the caller's bytes, and let every delivery be durable Deferring publication onto the worker made the caller's array a shared reference: a caller that reuses or refills its buffer after putData or transferFile returns had the worker publish whatever the array said by then rather than what was handed over. Both clone before scheduling. And a message or removal always goes through the spool now, even when a listener is registered. Delivering directly looked like the fast path and left the same hole the spool exists to close -- deliverMessage only QUEUES onto the EDT, so a process killed between the queueing and the callback lost a one-shot the Data Layer does not retain. One record, one ordering, and a release that happens when the listener has actually had it. The cost is a small write per message on a Data Layer worker, which is not a UI thread. A reply-bearing request keeps its direct path, and deliberately: its answer cannot be deferred, because the peer times out long before a later launch. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/wearable/CN1WearableBridge.java | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) 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 0d1641b3c4c..d69b78daaa7 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 @@ -1195,7 +1195,10 @@ static void cancelReplyTimeout(int replyToken) { public void putData(String path, byte[] payload) { final String p = path; - final byte[] body = payload == null ? new byte[0] : payload; + // CLONED, not referenced. Publication is deferred onto the worker below, so a caller that + // reuses or refills its buffer after this returns would otherwise have the worker publish + // whatever the array says by then rather than what was handed over. + final byte[] body = payload == null ? new byte[0] : (byte[]) payload.clone(); // OFF the caller's thread, because the caller is usually the EDT. // // Allocating the sequence advances the logical-clock floor, and the floor is written with a @@ -1402,12 +1405,16 @@ static void ensureAppRunning() { private static final String SPOOL_REMOVAL = "r"; - /// Hands a one-shot message to a live listener, or writes it down for the next process. + /// Writes a one-shot message down, then hands it over. + /// + /// ALWAYS through the spool, even when a listener is registered. Delivering directly looked + /// like the fast path and left the same hole the spool exists to close: deliverMessage only + /// queues onto the EDT, so a process killed between the queueing and the callback lost a + /// message the Data Layer does not retain. Going through the spool means one record, one + /// ordering, and a release that happens when the listener has actually had it. + /// + /// The cost is a small write per message on a Data Layer worker, which is not a UI thread. static void spoolOrDeliverMessage(Context context, String path, byte[] payload) { - if (deliverableNow(context, true)) { - WearableConnection.deliverMessage(path, payload, 0); - return; - } if (!spool(context, SPOOL_MESSAGE, path, payload)) { // The spool is the durable half; if it could not be written, the in-memory queue is // still better than dropping the message outright. @@ -1455,10 +1462,6 @@ static void spoolOrDeliverRequest(Context context, String path, byte[] payload, /// The same for a removal, whose item no longer exists to be replayed from. static void spoolOrDeliverRemoval(Context context, String path) { - if (deliverableNow(context, false)) { - WearableConnection.deliverDataRemoved(path); - return; - } if (!spool(context, SPOOL_REMOVAL, path, null)) { WearableConnection.deliverDataRemoved(path); return; @@ -2349,7 +2352,9 @@ private String[] enumerateDataPathsBlocking() { public void transferFile(String path, String name, byte[] contents) { final String p = path; final String fileName = name == null ? "file" : name; - final byte[] body = contents == null ? new byte[0] : contents; + // Cloned for the same reason as putData's payload: the bytes are read on the worker, after + // this method has returned to a caller that is free to reuse the array. + final byte[] body = contents == null ? new byte[0] : (byte[]) contents.clone(); // Off the caller's thread for the same reason putData is: allocating the sequence advances // the durable clock floor with a synchronous commit(), and this is called from application // code that is usually on the EDT. The transfer worker is single-threaded, so transfers From d5c57aa35487117aafc88a662a8c175364e55e92 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:19:13 +0700 Subject: [PATCH 244/250] Wearables: removals are ordered against the publications they follow Deferring putData onto the worker left removeData issuing its delete straight from the caller, so `putData(path, value); removeData(path);` could delete first and let the put land behind it -- leaving the value published after the very call that removed it. The delete goes to the same single-threaded worker now, so the Data Layer sees these operations in the order the application asked for them. That is every mutation of the data namespace on one queue: the publication, the transfer, the transfer acknowledgement and now the removal. What stays off it is sendMessage, which is a live delivery rather than a change to a published value and has no ordering relationship with one. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/wearable/CN1WearableBridge.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 d69b78daaa7..0f000ae7cb5 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 @@ -2089,6 +2089,21 @@ static DataMap valueMap(DataItem item) { } public void removeData(String path) { + final String p = path; + // On the SAME worker the publications go to, or the two operations race. putData defers its + // publication, so `putData(path, value); removeData(path);` could delete first and let the + // put land behind it -- leaving the value published after the call that removed it. One + // single-threaded worker for every mutation of this namespace means the Data Layer sees + // them in the order the application asked for them. + transferTimer.schedule(new java.util.TimerTask() { + public void run() { + deleteData(p); + } + }, 0); + } + + /// Issues one removal, on the worker that owns the ordering. + private void deleteData(String path) { // Remembered before the delete is issued. removeData targets wear://*/... -- a WILDCARD -- // so Play services deletes every authority's replica and the resulting buffer can carry // tombstones whose authority is a PEER node even though this app initiated the removal. From 0e01ba6e96cb1fb3eee291ab4b3bacfd7f35d98c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:03:20 +0700 Subject: [PATCH 245/250] Wearables: a transfer acknowledgement reaches the sender that is waiting for it The acknowledgement is published under /cnxk and the listener service's manifest filter routed only /cn1, so the sender was never woken when a receiver confirmed a delivery. Nothing else arms the sender's sweep -- the bridge registers no DataClient listener of its own -- so an acknowledged transfer stayed replicated on both devices until the seven-day hard cap, unless the app happened to send another file or restart. For a large file that is days of storage nobody is using. The filter now carries both namespaces, and the service treats an acknowledgement as what it is: bookkeeping between the two ports, never an app-visible callback, on a path no application names. It arms the sweep through the same coalescing entry point every other caller uses, and builds a bridge if the wake-up found none -- an acknowledgement-only wake-up is exactly the cold case that needs one. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/AndroidGradleBuilder.java | 7 ++++++ .../builders/wearable/CN1WearableBridge.java | 22 +++++++++++++++++++ .../wearable/CN1WearableListenerService.java | 14 ++++++++++++ 3 files changed, 43 insertions(+) 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 33c31c80b0d..b85b7a9af01 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 @@ -4192,6 +4192,13 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { + " \n" + " \n" + " \n" + // The acknowledgement namespace is NOT under /cn1, and a filter that lists only + // that one never woke the sender when a receiver confirmed a delivery. Nothing + // else arms the sender's sweep either -- the bridge registers no DataClient + // listener -- so an acknowledged transfer stayed replicated on both devices + // until the seven-day hard cap, or until the app happened to send another file + // or restart. For a large file that is days of storage nobody is using. + + " \n" + " \n" + " \n"; } 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 0f000ae7cb5..d08d613847b 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 @@ -4406,6 +4406,28 @@ private static void publishTransferAck(Context context, Uri uri) { } } + /// Arms the sender's sweep because a receiver has acknowledged one of our transfers. + /// + /// The acknowledgement is the only signal that a delivered file may be dropped early; without + /// it the sweep runs on the hard cap, days later. Coalesced through expireOwnTransfers like + /// every other caller, so a burst of acknowledgements adds no timer entries. + /// + /// A service process with no bridge yet still gets one: the sweep needs the DataClient, and + /// building the bridge here is what a cold acknowledgement-only wake-up is for. + static void sweepAfterAcknowledgement(Context context) { + CN1WearableBridge live = current; + if (live == null && context != null) { + try { + live = new CN1WearableBridge(context); + } catch (Throwable unavailable) { + return; + } + } + if (live != null) { + live.expireOwnTransfers(); + } + } + /** * Gives up the in-memory claim on a transfer that was never delivered. * 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 1db52cb23c5..a1b9d479aae 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 @@ -232,6 +232,20 @@ private void handleDataChanged(java.lang.Iterable events, long remova // app code fire an extra callback on Android only, so an app that acts on a change // processed its own write twice. boolean transferItem = CN1WearableBridge.isTransferPath(path); + // A receiver's acknowledgement of one of OUR transfers. It is the event that says the + // file has been taken and our copy can go, and the sweep it arms is the only thing that + // removes that copy before the hard cap -- the bridge registers no DataClient listener + // of its own, so without this the sender sat on a delivered file for seven days unless + // it happened to send another or restart. + // + // Never an app-visible callback: it is bookkeeping between the two ports, on a path no + // application ever names. + if (path != null && CN1WearableBridge.ackedTransferKey(path) != null) { + if (isFromAKnownHost(uri) && event.getType() != DataEvent.TYPE_DELETED) { + CN1WearableBridge.sweepAfterAcknowledgement(getApplicationContext()); + } + continue; + } if (path == null || !isFromAKnownHost(uri) || (!transferItem && !path.startsWith(CN1WearableBridge.pathPrefix()))) { continue; From e2c3d20e362bb3361c94f59d76b643799ca9ad10 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 12 Aug 2026 04:53:19 +0700 Subject: [PATCH 246/250] Wearables: a failed read is not an absence, a live request stays durable, both watch slices required getData collapsed a failed query into null when nothing was cached, which is the public API's "nothing is published" -- so the first read in a cold process that happened to time out told the caller an item was gone while it sat there, and a caller acting on that discards valid state. A cold read is retried now, and the retries are spent only when there is no snapshot to fall back on, since a cached value settles the question on the first failure. When it still cannot be answered the log says so, because the return value cannot. A live request is written down before it is delivered. The live delivery only queues onto the EDT, so a process killed in that window lost the request entirely -- the sender times out either way, but the receiver never learned it had been asked. The record survives and replays on the next launch as a plain message, which is what the request has become by then; the delivery confirmation releases it, so the normal case leaves nothing behind and nothing is delivered twice. And a vendored bundle needs BOTH watch variants before it is linked. The target builds for watchos and watchsimulator, so an .xcframework carrying only the device library, or a .framework declaring only WatchOS, links on hardware and breaks every simulator run -- the same asymmetry the system-framework check already guards by requiring presence in both SDKs. Four bundle shapes checked. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/WatchNativeBuilder.java | 24 +++- .../builders/wearable/CN1WearableBridge.java | 108 +++++++++++++++--- .../builders/WatchNativeBuilderTest.java | 5 + 3 files changed, 116 insertions(+), 21 deletions(-) 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 fd71a58a13f..556edbf610a 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 @@ -1897,15 +1897,31 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, .append(" return false\n") .append(" end\n") .append(" return false unless info.is_a?(Hash)\n") + // BOTH watch variants, because the target is built for both destinations. A plain + // .framework declaring only WatchOS links on a device and fails in the simulator + // with no compatible slice, which is the same asymmetry the system-framework check + // above already guards against by requiring presence in both SDKs. .append(" platforms = info['CFBundleSupportedPlatforms']\n") - .append(" if platforms.is_a?(Array) && platforms.any? { |p| " - + "p.to_s.downcase.start_with?('watch') }\n") - .append(" return true\n") + .append(" if platforms.is_a?(Array)\n") + .append(" names = platforms.map { |p| p.to_s.downcase }\n") + .append(" if names.any? { |p| p.start_with?('watch') }\n") + .append(" return names.include?('watchos') && " + + "names.include?('watchsimulator')\n") + .append(" end\n") .append(" end\n") + // An .xcframework lists one entry per platform+variant: the device library has no + // Variant, the simulator one carries Variant = 'simulator'. A bundle with only the + // device library builds on hardware and breaks every simulator run. .append(" libs = info['AvailableLibraries']\n") .append(" return false unless libs.is_a?(Array)\n") - .append(" libs.any? { |l| l.is_a?(Hash) && " + .append(" watch = libs.select { |l| l.is_a?(Hash) && " + "l['SupportedPlatform'].to_s.downcase.start_with?('watch') }\n") + .append(" return false if watch.empty?\n") + .append(" device = watch.any? { |l| " + + "l['SupportedPlatformVariant'].to_s.strip.empty? }\n") + .append(" simulator = watch.any? { |l| " + + "l['SupportedPlatformVariant'].to_s.downcase == 'simulator' }\n") + .append(" device && simulator\n") .append("end\n") .append("vendored_linked = false\n") .append("watch_sdks = ['watchos', 'watchsimulator'].map { |sdk| " 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 d08d613847b..062890ee10e 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 @@ -1444,10 +1444,25 @@ static void spoolOrDeliverMessage(Context context, String path, byte[] payload) static void spoolOrDeliverRequest(Context context, String path, byte[] payload, int peerToken, String sourceNodeId) { if (deliverableNow(context, true)) { + // Written down FIRST, then delivered live. The live delivery only queues onto the EDT, + // so a process killed in that window used to lose the request entirely -- the sender + // times out either way, but the receiver never learned it had been asked. The record + // survives that and replays on the next launch as a plain message, which is what the + // request has become by then. + // + // Released by the delivery confirmation below, so the normal case leaves nothing + // behind and the app is not handed the same request twice. + final Context c = context; + final String key = spoolOne(context, SPOOL_MESSAGE, path, payload); // The peer's token is unique only on the peer, so trade it for a locally unique one // keyed to the node that asked; two watches can otherwise pick the same number. int localToken = rememberRequestOrigin(peerToken, sourceNodeId); - WearableConnection.deliverMessage(path, payload, localToken); + WearableConnection.deliverMessage(path, payload, localToken, key == null ? null + : new Runnable() { + public void run() { + releaseSpooled(c, key); + } + }); return; } android.util.Log.w("CN1Wearable", "no listener can answer the request on " + path @@ -1515,6 +1530,42 @@ private static boolean spoolBusy() { } } + /// Writes one record and returns its key, or null if it could not be written. + /// + /// The key is what lets a LIVE delivery release its own durable copy once a listener has had + /// it. Everything else uses the boolean form below and lets the drain do the releasing. + private static String spoolOne(Context context, String kind, String path, byte[] payload) { + Context c = spoolContext(context); + if (c == null) { + return null; + } + synchronized (SPOOL_LOCK) { + try { + android.content.SharedPreferences prefs = + c.getSharedPreferences(SPOOL_PREFS, Context.MODE_PRIVATE); + long seq = prefs.getLong(SPOOL_SEQ_KEY, 0) + 1; + String key = spoolKey(seq); + android.content.SharedPreferences.Editor edit = prefs.edit(); + edit.putLong(SPOOL_SEQ_KEY, seq); + edit.putString(key, "0|" + kind + "|" + encode(path) + "|" + + (payload == null ? "" : android.util.Base64.encodeToString( + payload, android.util.Base64.NO_WRAP))); + trimSpool(prefs, edit); + spoolDirty = true; + // Claimed on the spot: this record is being delivered live, so the drain must not + // pick it up as well and hand the app the same payload twice. + SPOOL_IN_FLIGHT.add(key); + if (!edit.commit()) { + SPOOL_IN_FLIGHT.remove(key); + return null; + } + return key; + } catch (Throwable unavailable) { + return null; + } + } + } + private static boolean spool(Context context, String kind, String path, byte[] payload) { Context c = spoolContext(context); if (c == null) { @@ -1959,25 +2010,48 @@ private byte[] resolveDataBlocking(String path) { // kept whichever item the buffer yielded first -- so once resolveValue() gained the // publisher tie-break, getData() could return a different value than the listener had just // delivered for the same path. One implementation, one answer. - String before = deliveredStamp(path); - try { - ResolvedValue v = resolveValue(context, path); - byte[] out = v == null ? null : v.payload; - // Only if no delivery moved this path while the query was blocked -- otherwise this - // older snapshot would outlive the newer one a delivery has already recorded. - rememberValueIfStampUnchanged(path, before, out); - return out; - } catch (java.io.IOException unavailable) { - // A failed query is NOT an empty path, and the two must not collapse into the same - // answer: the public getter documents null as "no value here", so returning it after a - // timeout invites a caller to clear state for a path that is still published. - // resolveValue throws precisely to keep them apart. The last known snapshot is the - // honest answer -- it is what this device last saw -- and null only when there is not - // even that. - return cachedValue(path); + // RETRIED, because a cold process has no snapshot to fall back on. A failed query and an + // empty path are different facts, and the getter has one value -- null -- for both: the + // first call in a new process that happens to time out therefore told the caller "nothing + // is published" about an item that is still there, and a caller acting on that discards + // valid state. A cached value makes the distinction moot, so the retries are spent only + // when there is nothing to fall back on. + java.io.IOException lastFailure = null; + for (int attempt = 0; attempt < COLD_READ_ATTEMPTS; attempt++) { + String before = deliveredStamp(path); + try { + ResolvedValue v = resolveValue(context, path); + byte[] out = v == null ? null : v.payload; + // Only if no delivery moved this path while the query was blocked -- otherwise this + // older snapshot would outlive the newer one a delivery has already recorded. + rememberValueIfStampUnchanged(path, before, out); + return out; + } catch (java.io.IOException unavailable) { + lastFailure = unavailable; + byte[] known = cachedValue(path); + if (known != null) { + // The last known snapshot is the honest answer -- it is what this device last + // saw -- and it settles the question without another round trip. + return known; + } + } } + // Out of attempts with nothing cached. The answer returned here is indistinguishable from + // an authoritative absence, which is exactly the problem, so it is at least SAID: a caller + // that clears state on null has this line in the log explaining why it was wrong. + android.util.Log.w("CN1Wearable", "could not read " + path + " after " + + COLD_READ_ATTEMPTS + " attempts (" + lastFailure + + "); reporting no value, which is not the same as knowing there is none"); + return null; } + /// How many times a read is attempted before it gives up with nothing cached. + /// + /// Only reached when this device has never seen the path: with a snapshot in hand the first + /// failure returns it, so the extra round trips are spent solely on the case that would + /// otherwise report an absence it cannot vouch for. + private static final int COLD_READ_ATTEMPTS = 3; + /// Populates the cache for one path in the background, at most once per path per process. /// /// Only for the latency-sensitive path, which cannot block. The query itself is the ordinary 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 7f9a5bc6a2b..cd4626d8e52 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 @@ -804,6 +804,11 @@ void vendoredFrameworksDeclaringWatchosAreLinkedEmbeddedAndFound(@TempDir Path t assertTrue(ruby.contains("AvailableLibraries"), ruby); assertTrue(ruby.contains("cn1_watch_bundle_supports_watchos(ref)"), "a non-SDKROOT reference must be judged by its own bundle: " + ruby); + // BOTH watch variants: the target builds for watchos AND watchsimulator, so a bundle + // carrying only the device library links on hardware and breaks every simulator run. + assertTrue(ruby.contains("SupportedPlatformVariant"), ruby); + assertTrue(ruby.contains("device && simulator"), ruby); + assertTrue(ruby.contains("names.include?('watchsimulator')"), ruby); // Linking alone is not enough. The linker has to be told where the binary lives, and a // dynamic framework has to be copied into the watch bundle or it fails at install time. assertTrue(ruby.contains("config.build_settings['FRAMEWORK_SEARCH_PATHS'] = paths"), ruby); From 00526a7515f3db3599334942461f0f6d45f05e72 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:58:29 +0700 Subject: [PATCH 247/250] Wearables: a message is snapshotted before the send can outlive its call sendMessage returns while the node query is still running when the cache is stale -- the first send of a process, typically -- and hands the caller's array to fanOut from the completion callback. A caller that reuses its buffer, which is the natural thing to do with one, therefore had the peer receive whatever the array said by then rather than the message it passed. The publication paths already copy for this reason; this is the send that can outlive its call, and it was the one still holding a reference. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/builders/wearable/CN1WearableBridge.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 062890ee10e..36b0fc0230f 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 @@ -953,6 +953,12 @@ private static List idsOf(List nodes) { // --- messages ----------------------------------------------------------- public void sendMessage(final String path, final byte[] payload, final int replyToken) { + // CLONED before anything can defer the send. The stale-cache branch below returns while the + // node query is still running and hands the caller's array to fanOut from a callback, so a + // caller that reuses its buffer -- the natural thing to do with one -- had the peer receive + // whatever the array said by then rather than the message it passed. The publication paths + // already copy for the same reason; this one is the send that can outlive its call. + final byte[] body = payload == null ? null : (byte[]) payload.clone(); if (System.currentTimeMillis() - cachedNodesStamp > NODE_CACHE_MILLIS) { // The cache is empty or stale. Sending now would fan out to a list that predates the // current connection state and report "no nearby device" while a watch is sitting right @@ -983,12 +989,12 @@ public void onComplete(com.google.android.gms.tasks.Task> task) { // stale-but-real node list still addresses the peer, whereas an empty // one silently drops this message (or fails its reply handler) purely // because a refresh happened to time out. - fanOut(path, payload, replyToken); + fanOut(path, body, replyToken); } }); return; } - fanOut(path, payload, replyToken); + fanOut(path, body, replyToken); } private void fanOut(String path, byte[] payload, final int replyToken) { From 20b966ea5db7ea60e70c46778521f9e6c0b974aa Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:40:37 +0700 Subject: [PATCH 248/250] Wearables: a peer notification is not proof of identity onPeerConnected arrives on an EXPORTED service with no binding permission, so any installed app can invoke it with a node id of its choosing -- and the id went straight into the provenance allowlist that isKnownNode consults first. The same app could then forge a message or data callback under that id, launch this app and reach its listeners, walking past the check that exists to stop exactly that. Peer notifications are state, not identity. The callback no longer feeds the allowlist: trust comes only from asking Play services -- connectedNodeIds, capabilityNodeIds, getLocalNode -- which isKnownNode already does for itself on the first event it cannot vouch for. A pushed CONNECT no longer inserts its node into the reachable snapshot either, since isPaired(), isReachable() and fanOut answer from that; it is treated as a hint that something changed and answered with a real query. A DISCONNECT is still applied as it arrives -- removing a node can only understate what is reachable, and the next query corrects it. Audited the other feeders while here: every remaining rememberAll() takes the result of a getConnectedNodes() query, and capabilityChanged writes only the bonded set, which no provenance decision reads. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/wearable/CN1WearableBridge.java | 53 +++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) 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 36b0fc0230f..a16b86339ad 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 @@ -724,9 +724,15 @@ private static boolean sameIds(List a, List b) { /// about as still present (or still absent) for the rest of the cache lifetime. static void peerChanged(Node peer, boolean connected) { CN1WearableBridge b = current; - if (peer != null && connected) { - rememberNode(peer.getId()); - } + // NOT rememberNode. This arrives on a callback of an EXPORTED service with no binding + // permission, so any installed app can invoke it with a node id of its choosing -- and + // adding that id here put it straight into the provenance allowlist that isKnownNode + // consults first, letting the same app then forge a message or data callback under it, + // launch this app and reach its listeners. + // + // Peer notifications are state, not proof of identity. Trust comes only from asking Play + // services -- connectedNodeIds, capabilityNodeIds, getLocalNode -- which isKnownNode does + // for itself on the first event it cannot already vouch for. if (b == null) { WearableConnection.notifyStateChanged(); return; @@ -749,8 +755,15 @@ private void applyPeerChange(Node peer, boolean connected) { updated.remove(i); } } + // A DISCONNECT is applied as it arrives and a CONNECT is not. Removing a node can only + // understate what is reachable, which the next query corrects; adding one on the word + // of an exported callback would let any installed app conjure a peer into the snapshot + // that isPaired() and isReachable() answer from, and that fanOut would then address. + // + // The connect still does its job -- it is a hint that the picture changed, and the + // refresh below asks Play services what actually changed. if (connected) { - updated.add(peer); + refreshConnectedAfterPeerHint(); } } cachedNodes = updated; @@ -762,6 +775,38 @@ private void applyPeerChange(Node peer, boolean connected) { nodesGeneration++; } + /// Re-asks Play services for the connected set after an unverified peer hint. + /// + /// The hint itself carries no authority, so the answer has to come from the same query the rest + /// of the provenance path uses. Off the calling thread because that thread is a Play services + /// callback and the query blocks; coalesced by the timer, so a burst of hints costs one round + /// trip rather than one each. + private void refreshConnectedAfterPeerHint() { + transferTimer.schedule(new java.util.TimerTask() { + public void run() { + try { + List fresh = Tasks.await(nodeClient.getConnectedNodes(), + TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (fresh == null) { + return; + } + synchronized (nodesLock) { + cachedNodes = fresh; + cachedNodesStamp = System.currentTimeMillis(); + nodesGeneration++; + } + // Verified, so these MAY enter the allowlist -- this is the query isKnownNode + // would have made anyway. + rememberAll(fresh); + WearableConnection.notifyStateChanged(); + } catch (Throwable unavailable) { + // The previous snapshot stands. A hint that could not be confirmed changes + // nothing, which is the point. + } + } + }, 0); + } + /// The live bridge, so the listener service can push state into it. The service and the bridge /// are created independently by Android, which is why this is not a constructor argument. private static volatile CN1WearableBridge current; From 5f9429f58868eb0beed5298b3465713fae04f71d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:17:52 +0700 Subject: [PATCH 249/250] Wearables: a transition cannot overtake the batch being delivered Clearing the phase queue before delivering is necessary -- a delivery runs translated code and must not be replayed re-entrantly -- but on its own it left a window. The bootstrap thread could copy a queued BACKGROUND phase and be descheduled; the main thread's foreground transition then found an empty queue, delivered itself immediately, and the older background arrived after it. The app ends up stopped and minimized while visibly on screen. An empty queue is therefore the wrong question. A new transition asks whether a drain is in flight, and queues behind the batch if one is; the drain loops until the queue is empty, so it delivers whatever arrived while it was working and ownership is released in one place. A static archive now needs a simulator-capable slice as well as a watch device one: arm64_32 and armv7k exist nowhere but watchOS, but the target also builds for watchsimulator, and a device-only archive fails every simulator run. The log names the architectures found so a rejection is attributable. And a DEFINEDNESS test is not a platform test. TargetConditionals defines every one of its macros on every platform, as 0 or 1, so `#ifdef TARGET_OS_IOS` and `#if defined(TARGET_OS_IOS)` are both true on watchOS and the branch compiles there -- reading them as iOS-only dropped the package imported inside an arm the watch does build. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/CN1WatchRuntime.m | 43 +++++++++++++++++-- .../builders/WatchNativeBuilder.java | 19 +++++++- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1WatchRuntime.m b/Ports/iOSPort/nativeSources/CN1WatchRuntime.m index 1738ffc2aa5..ecbaaedf575 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchRuntime.m +++ b/Ports/iOSPort/nativeSources/CN1WatchRuntime.m @@ -228,6 +228,18 @@ void cn1_watch_runtime_markJavaReady(void) { /// is stopped while the watch is in the background, which is exactly when a queued background /// transition is waiting, so a foreground arriving next has to flush the backlog itself before /// recording anything of its own. +/// True while a drain is copying and delivering the queue. +/// +/// Clearing the queue before delivering is necessary -- a delivery runs translated code and must +/// not be replayed re-entrantly -- but on its own it opened a window: the bootstrap thread could +/// copy a queued BACKGROUND phase and be descheduled, the main thread's foreground transition then +/// found an empty queue and delivered itself first, and the older background arrived after it. The +/// app ends up stopped and minimized while visibly on screen. +/// +/// So the queue being empty is not the question a new transition should ask. It asks whether a +/// drain is in flight, and if one is, it queues behind the batch already being delivered. +static BOOL cn1WatchDraining = NO; + static void cn1WatchReplayPendingPhase(void) { if (!cn1WatchJavaReady()) { return; @@ -235,6 +247,12 @@ static void cn1WatchReplayPendingPhase(void) { int pending[CN1_WATCH_MAX_PENDING_PHASES]; int count; pthread_mutex_lock(&cn1WatchPhaseLock); + if (cn1WatchDraining) { + // Another thread owns the batch. Its own loop picks up anything queued meanwhile. + pthread_mutex_unlock(&cn1WatchPhaseLock); + return; + } + cn1WatchDraining = YES; count = cn1WatchPendingPhaseCount; for (int i = 0; i < count; i++) { pending[i] = cn1WatchPendingPhases[i]; @@ -243,9 +261,25 @@ static void cn1WatchReplayPendingPhase(void) { // a re-entrant call nor the drain thread must replay what is already on its way. cn1WatchPendingPhaseCount = 0; pthread_mutex_unlock(&cn1WatchPhaseLock); - for (int i = 0; i < count; i++) { - cn1WatchDeliverPhase(pending[i]); + while (count > 0) { + for (int i = 0; i < count; i++) { + cn1WatchDeliverPhase(pending[i]); + } + // Again, because a transition that arrived during those deliveries queued behind them + // rather than overtaking them -- and this drain is the one that owes it a delivery. + pthread_mutex_lock(&cn1WatchPhaseLock); + count = cn1WatchPendingPhaseCount; + for (int i = 0; i < count; i++) { + pending[i] = cn1WatchPendingPhases[i]; + } + cn1WatchPendingPhaseCount = 0; + pthread_mutex_unlock(&cn1WatchPhaseLock); } + // One place to release ownership, reached whether the queue was empty on entry or emptied by + // the loop. Anything queued after this point finds no drain in flight and starts its own. + pthread_mutex_lock(&cn1WatchPhaseLock); + cn1WatchDraining = NO; + pthread_mutex_unlock(&cn1WatchPhaseLock); } /// Records or delivers one transition, preserving order against anything still queued. @@ -308,7 +342,10 @@ static void cn1WatchArmPhaseDrain(void) { static void cn1WatchHandlePhase(int phase) { cn1WatchReplayPendingPhase(); pthread_mutex_lock(&cn1WatchPhaseLock); - BOOL queued = !cn1WatchJavaReady() || cn1WatchPendingPhaseCount > 0; + // cn1WatchDraining, not just a non-empty queue: a drain that has already copied the batch has + // emptied the queue while the older phases are still on their way, and delivering directly + // into that window is what let a foreground overtake the background before it. + BOOL queued = !cn1WatchJavaReady() || cn1WatchDraining || cn1WatchPendingPhaseCount > 0; if (queued) { cn1WatchQueuePhase(phase); } 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 556edbf610a..b7f8c33c295 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 @@ -1876,9 +1876,18 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // A thin archive answers nothing to lipo -archs; ask the file itself. .append(" archs = `file \"#{path}\" 2>/dev/null`.scan(" + "/arm64_32|armv7k/) if archs.empty?\n") - .append(" watch = archs.any? { |a| a == 'arm64_32' || a == 'armv7k' }\n") + // A watch DEVICE slice is unambiguous -- arm64_32 and armv7k exist nowhere else -- + // but the target also builds for watchsimulator, and an archive with only the + // device slice fails every simulator run. A simulator slice is x86_64 (Intel) or + // arm64 (Apple silicon); arm64 alone cannot be told apart from an iOS device slice + // by architecture, so the pair is what is required, and the log names what was + // found so a mismatch is attributable rather than mysterious. + .append(" device = archs.any? { |a| a == 'arm64_32' || a == 'armv7k' }\n") + .append(" simulator = archs.any? { |a| a == 'x86_64' || a == 'arm64' }\n") + .append(" watch = device && simulator\n") .append(" puts \"[watchNative] #{File.basename(path)} #{watch ? 'has' : 'has no'}" - + " watchOS slice (#{archs.empty? ? 'unknown' : archs.join(' ')})\"\n") + + " usable watchOS slices (#{archs.empty? ? 'unknown' : archs.join(' ')}; " + + "device=#{device} simulator=#{simulator})\"\n") .append(" watch\n") .append("end\n"); @@ -2135,6 +2144,12 @@ String buildXcodeScript(BuildRequest request, File tmpFile, String buildVersion, // os() expressions, and `#if !TARGET_OS_WATCH` around a phone-only @import is the // standard spelling. Treating it as unevaluable kept the import, which attached an // iOS-only package to the watch target over code the compiler excludes. + // A DEFINEDNESS test is not a platform test. TargetConditionals defines every one + // of these macros on every platform -- as 0 or 1 -- so `#ifdef TARGET_OS_IOS` and + // `#if defined(TARGET_OS_IOS)` are both TRUE on watchOS and the branch compiles + // there. Reading them as iOS-only removed an arm the watch does build and dropped + // the package imported inside it. + .append(" return false if c =~ /\\A#(ifdef|ifndef)\\b/ || c.include?('defined(')\n") .append(" return true if c =~ /!TARGET_OS_WATCH\\b/\n") .append(" unless c =~ /\\bTARGET_OS_WATCH\\b/\n") // NEGATED is the opposite answer. `!TARGET_OS_IOS` is TRUE on watchOS, so that arm From df8757d74cefaf2806afcb3090dd7af9fd779ae2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:47:38 +0700 Subject: [PATCH 250/250] Wearables: a scanner callback that cannot name its class says so The scanningClass contract now states what a null means and what a consumer owes it: no attribution, and DISCARD whatever was being held. That is the client half of a daemon-side gap -- its non-ASM fallback parser, used for every class compiled to a version ASM cannot read, never named the class it was reading, so a setWriteToStore(true) found there was credited to the last ASM-scanned class and the per-root health walk entitled the wrong target or neither. The client's Executor has no fallback path, so the fix itself lives in the BuildDaemon copy; what belongs here is the contract both sides implement, and IPhoneBuilder already ignores a null rather than keeping the previous class. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/main/java/com/codename1/builders/Executor.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 39775846a9d..14c7a87224e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -510,7 +510,13 @@ public default void usesClassMethodWithBooleanArgument(String cls, * entitlement the phone's provisioning profile does not carry fails release signing. * Recording the caller lets a per-root walk decide which target actually did it.

* - * @param internalName the caller's internal name, slashes and all + *

Null means the scanner could not name the class it is about to read -- the + * non-ASM fallback parser reports it that way when the constant pool will not resolve. + * A consumer must treat that as "no attribution" and DISCARD whatever it was holding: + * keeping the previous class would credit this one's calls to an unrelated one, which is + * worse than not attributing them at all.

+ * + * @param internalName the caller's internal name, slashes and all, or null when unknown */ public default void scanningClass(String internalName) { }