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 01/67] 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 02/67] 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 03/67] 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 04/67] 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 05/67] 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 06/67] 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 07/67] 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 08/67] 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 09/67] 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 10/67] 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 11/67] 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 12/67] 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 13/67] 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 14/67] 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 15/67] 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 16/67] 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 + + + + + + + + +