diff --git a/.github/workflows/parparvm-tests-windows.yml b/.github/workflows/parparvm-tests-windows.yml index 64d370c8540..27818330f0e 100644 --- a/.github/workflows/parparvm-tests-windows.yml +++ b/.github/workflows/parparvm-tests-windows.yml @@ -668,3 +668,88 @@ jobs: echo "[windows-native-port arm64] FATAL: $me screenshot(s) streamed with no stored golden (missing_expected) -- add them to $REF_DIR."; fail=1 fi if [ "$fail" -ne 0 ]; then echo "Windows arm64 screenshot gate failed."; exit 1; fi + + # Widgets Board provider compile check: compiles the one nativeSources TU no + # other CI leg ever compiles -- cn1_windows_widgetboard.cpp, which is gated + # behind CN1_WIDGETBOARD (the default CMake build never defines it and the + # Linux xwin cross leg has no Windows App SDK) -- against the real Windows App + # SDK winmds + cppwinrt-generated C++/WinRT projections on a Windows host. + # This mirrors exactly what WindowsNativeBuilder does for windows.msix=true + # builds: clang-cl (the port is clang-cl-only by design -- cn1_globals.h uses + # `_Atomic` and `__thread`, which MSVC cl.exe rejects in C++ -- and the + # builder sets CMAKE_CXX_COMPILER to clang-cl on Windows hosts and cross + # builds alike), /std:c++17 (the translator-emitted CMAKE_CXX_STANDARD), + # /EHsc (C++/WinRT requires exceptions) and /DCN1_WIDGETBOARD=1 plus the + # WinAppSDK include dir. The same step also re-compiles + # cn1_windows_widgets.cpp (the plain-exe floating-widget TU) with the + # Windows-host toolchain as a second real-compiler check beyond the Linux + # xwin leg. Compile-only (/c): any compile error fails the job. + widgetboard-compile-check: + name: widgetboard-compile-check (x64) + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@v6 + + # clang-cl uses the MSVC STL/CRT headers and the Windows SDK, which reach + # the compiler through the INCLUDE/LIB environment vcvarsall sets up. + - name: Set up MSVC environment + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: amd64 + + # The Microsoft.WindowsAppSDK nupkg ships winmd metadata + MddBootstrap.h + # but NO prebuilt C++/WinRT headers, so the projection (winrt/base.h, the + # referenced Windows.* namespaces from the OS metadata, and + # winrt/Microsoft.Windows.Widgets[.Providers].h) is generated with + # cppwinrt.exe. Versions are pinned for reproducibility: the 1.5 servicing + # release matches the 1.5 series the provider bootstraps at runtime + # (CN1_WINAPPSDK_MAJORMINOR 0x00010005 in the TU). nuget.exe is + # preinstalled on the windows runners. + - name: Fetch Windows App SDK + generate C++/WinRT projections + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $wasdkVer = '1.5.250108004' + $cppwinrtVer = '2.0.240405.15' + nuget install Microsoft.WindowsAppSDK -Version $wasdkVer -OutputDirectory nupkgs -NonInteractive + if ($LASTEXITCODE -ne 0) { throw "nuget install Microsoft.WindowsAppSDK failed" } + nuget install Microsoft.Windows.CppWinRT -Version $cppwinrtVer -OutputDirectory nupkgs -NonInteractive + if ($LASTEXITCODE -ne 0) { throw "nuget install Microsoft.Windows.CppWinRT failed" } + $wasdk = "nupkgs/Microsoft.WindowsAppSDK.$wasdkVer" + $cppwinrt = "nupkgs/Microsoft.Windows.CppWinRT.$cppwinrtVer/bin/cppwinrt.exe" + # -in local merges the OS metadata (C:\Windows\System32\WinMetadata) + # so the Windows.Foundation types the Widgets winmd references are + # projected by the same cppwinrt version (mixing SDK-shipped headers + # with a different generator version trips base.h's version check). + & $cppwinrt -in "$wasdk/lib/uap10.0/Microsoft.Windows.Widgets.winmd" -in local -output winrt-gen + if ($LASTEXITCODE -ne 0) { throw "cppwinrt projection generation failed" } + if (!(Test-Path 'winrt-gen/winrt/Microsoft.Windows.Widgets.Providers.h')) { + throw "cppwinrt did not produce winrt/Microsoft.Windows.Widgets.Providers.h" + } + "CN1_WASDK_DIR=$wasdk" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Widgets Board provider compile check (clang-cl) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + clang-cl --version + # The widgetboard TU deliberately avoids the cn1 headers (cn1_globals.h's + # C11 stdatomic conflicts with the C++ the winrt headers pull) and + # compiles at C++20: C++/WinRT at C++17 falls back to + # , which the modern MSVC STL rejects under clang + # (STL1009). This mirrors the -DCMAKE_CXX_STANDARD=20 WindowsNativeBuilder + # passes for windows.msix=true builds. + clang-cl /c /std:c++20 /EHsc /DCN1_WIDGETBOARD=1 /Iwinrt-gen "/I$env:CN1_WASDK_DIR/include" ` + Ports/WindowsPort/nativeSources/cn1_windows_widgetboard.cpp /Fowidgetboard.obj + if ($LASTEXITCODE -ne 0) { throw "cn1_windows_widgetboard.cpp failed to compile with CN1_WIDGETBOARD=1" } + # A translated app supplies the generated cn1_class_method_index.h; the + # floating-widget TU includes cn1_windows.h -> cn1_globals.h, which only + # needs this one constant from it here. + New-Item -ItemType Directory -Force stub-index | Out-Null + "#define cn1_array_start_offset 1000" | Set-Content stub-index/cn1_class_method_index.h -Encoding ascii + clang-cl /c /std:c++17 /EHsc /Istub-index /Ivm/ByteCodeTranslator/src ` + Ports/WindowsPort/nativeSources/cn1_windows_widgets.cpp /Fowidgets.obj + if ($LASTEXITCODE -ne 0) { throw "cn1_windows_widgets.cpp failed to compile" } + Write-Host "Widgets Board provider + floating-widget TUs compiled clean." diff --git a/CodenameOne/src/com/codename1/background/BackgroundFetch.java b/CodenameOne/src/com/codename1/background/BackgroundFetch.java index 31684dd95a5..1ad0385f3b6 100644 --- a/CodenameOne/src/com/codename1/background/BackgroundFetch.java +++ b/CodenameOne/src/com/codename1/background/BackgroundFetch.java @@ -50,6 +50,16 @@ /// Currently background fetch is supported on iOS, Android, and in the Simulator (simulated using timers when the app is paused). You should /// use the `com.codename1.ui.Display#isBackgroundFetchSupported()` method to find out if the current platform supports it. /// +/// Background Fetch and External Surfaces +/// +/// Background fetch is the refresh engine of home-screen widgets: call +/// `com.codename1.surfaces.Surfaces#publish(java.lang.String, com.codename1.surfaces.WidgetTimeline)` from +/// `performBackgroundFetch(long, com.codename1.util.Callback)` to update published widget content while the +/// app UI is not running -- publish is callable from any thread, including this callback. On Android a +/// widget whose `atEnd` timeline ran out will itself trigger this callback (throttled) to pull fresh +/// content. See `com.codename1.surfaces.Surfaces` and the `com.codename1.surfaces.spi` package +/// documentation for the full background update story. +/// /// Examples /// /// ```java @@ -203,6 +213,8 @@ public interface BackgroundFetch { /// - com.codename1.ui.Display.getPreferredBackgroundFetchInterval() /// /// - com.codename1.ui.Display.isBackgroundFetchSupported() + /// + /// - com.codename1.surfaces.Surfaces (publishing widget content from this callback) void performBackgroundFetch(long deadline, Callback onComplete); // PMD Fix: UnnecessaryModifier removed } diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index e2be5e7049e..d371eb50edc 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -5846,6 +5846,17 @@ public boolean isCarConnected() { return b != null && b.isConnected(); } + /// 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. + /// + /// #### Returns + /// + /// the surface bridge, or null when unsupported + public com.codename1.surfaces.spi.SurfaceBridge getSurfaceBridge() { + return null; + } + /// True if the device is a foldable or dual screen device. False on the base implementation. public boolean isFoldable() { return false; diff --git a/CodenameOne/src/com/codename1/surfaces/LiveActivity.java b/CodenameOne/src/com/codename1/surfaces/LiveActivity.java new file mode 100644 index 00000000000..f6799f561d7 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/LiveActivity.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import com.codename1.surfaces.spi.SurfaceBridge; + +import java.util.LinkedHashMap; +import java.util.Map; + +/// A running live activity: an ongoing-state surface (delivery, timer, ride, score) presented on +/// the iOS lock screen and Dynamic Island, as an ongoing Android notification, or as a floating +/// pill window on desktop. Start it with a descriptor and an initial state, then push fresh state +/// maps as the situation evolves -- updates ship only the state, the layout is re-interpolated on +/// the surface: +/// +/// ```java +/// LiveActivity delivery = LiveActivity.start(descriptor, initialState); +/// ... +/// delivery.update(stateMap("Arriving now", eta, 1.0f)); +/// delivery.end(null); +/// ``` +/// +/// On platforms without live activity support `start(...)` returns an inert handle whose methods +/// are safe no-ops ([#isActive()] returns false), so app code needs no platform checks. +public final class LiveActivity { + private final String id; + private boolean active; + + private LiveActivity(String id) { + this.id = id; + this.active = id != null; + } + + /// Returns true when this platform can present live activities. + /// + /// #### Returns + /// + /// true when live activities are supported + public static boolean isSupported() { + SurfaceBridge b = Surfaces.bridgeInternal(); + return b != null && b.isLiveActivitySupported(); + } + + /// Starts a live activity. On unsupported platforms (or when the platform refuses, e.g. the + /// user disabled live activities) this returns an inert handle rather than throwing. + /// + /// #### Parameters + /// + /// - `descriptor`: the activity layout and regions + /// - `initialState`: the initial state map, may be null + /// + /// #### Returns + /// + /// a handle to the running activity; check [#isActive()] to know whether it is live + public static LiveActivity start(LiveActivityDescriptor descriptor, + Map initialState) { + SurfaceBridge b = Surfaces.bridgeInternal(); + if (b == null || !b.isLiveActivitySupported()) { + return new LiveActivity(null); + } + Map images = new LinkedHashMap(); + String json = SurfaceSerializer.serializeLiveActivity(descriptor, initialState, images); + return new LiveActivity(b.startLiveActivity(json, images)); + } + + /// Pushes a fresh state map to the running activity. A no-op on an inert or ended handle. + /// + /// #### Parameters + /// + /// - `state`: the new state map + public void update(Map state) { + if (!active) { + return; + } + SurfaceBridge b = Surfaces.bridgeInternal(); + if (b != null) { + b.updateLiveActivity(id, SurfaceSerializer.serializeState(state)); + } + } + + /// Ends the activity, optionally showing a final state before the platform dismisses the + /// surface. A no-op on an inert or already-ended handle. + /// + /// #### Parameters + /// + /// - `finalState`: the final state to show, or null to keep the last state + public void end(Map finalState) { + end(finalState, false); + } + + /// Ends the activity. + /// + /// #### Parameters + /// + /// - `finalState`: the final state to show, or null to keep the last state + /// - `dismissImmediately`: true to remove the surface right away instead of letting the + /// platform linger on the final state + public void end(Map finalState, boolean dismissImmediately) { + if (!active) { + return; + } + active = false; + SurfaceBridge b = Surfaces.bridgeInternal(); + if (b != null) { + b.endLiveActivity(id, + finalState == null ? null : SurfaceSerializer.serializeState(finalState), + dismissImmediately); + } + } + + /// Returns true while the activity is running (false for inert handles and after `end`). + public boolean isActive() { + return active; + } + + /// Returns the platform id of the activity, or null for inert handles. Action events from + /// this activity carry the descriptor's activity type as their source. + public String getId() { + return id; + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/LiveActivityDescriptor.java b/CodenameOne/src/com/codename1/surfaces/LiveActivityDescriptor.java new file mode 100644 index 00000000000..04eb968927c --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/LiveActivityDescriptor.java @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +/// Describes a live activity: an ongoing-state surface (delivery, timer, ride, score) that stays +/// visible outside the app while it is in progress. The layout is built once from surface nodes +/// with `${key}` placeholders; each `LiveActivity.update(...)` then ships only a fresh state map. +/// +/// Platform lowering: +/// +/// - **iOS**: ActivityKit live activity. `setContent(...)` is the lock screen / banner +/// presentation; the `set...(...)` region setters fill the Dynamic Island (ignored on devices +/// without one). +/// - **Android**: an ongoing notification whose collapsed and expanded views render +/// `setCompactLeading`/`setCompactTrailing` and `setContent` respectively. +/// - **Desktop**: a floating always-on-top pill window rendering `setContent`. +public class LiveActivityDescriptor { + private final String activityType; + private SurfaceNode content; + private SurfaceNode compactLeading; + private SurfaceNode compactTrailing; + private SurfaceNode minimal; + private SurfaceNode expandedLeading; + private SurfaceNode expandedTrailing; + private SurfaceNode expandedCenter; + private SurfaceNode expandedBottom; + private SurfaceColor tint; + private String androidChannelId; + + /// Creates a live activity descriptor. + /// + /// #### Parameters + /// + /// - `activityType`: an app-defined type tag (e.g. `delivery`), delivered back as the source + /// of action events from this activity + public LiveActivityDescriptor(String activityType) { + this.activityType = activityType; + } + + /// Sets the primary presentation: the iOS lock screen / banner layout, the Android expanded + /// notification and the desktop pill content. + /// + /// #### Parameters + /// + /// - `root`: the layout root node + /// + /// #### Returns + /// + /// this descriptor, for chaining + public LiveActivityDescriptor setContent(SurfaceNode root) { + this.content = root; + return this; + } + + /// Sets the Dynamic Island compact leading region (left of the camera cutout). + /// + /// #### Parameters + /// + /// - `node`: the region content + /// + /// #### Returns + /// + /// this descriptor, for chaining + public LiveActivityDescriptor setCompactLeading(SurfaceNode node) { + this.compactLeading = node; + return this; + } + + /// Sets the Dynamic Island compact trailing region (right of the camera cutout). + /// + /// #### Parameters + /// + /// - `node`: the region content + /// + /// #### Returns + /// + /// this descriptor, for chaining + public LiveActivityDescriptor setCompactTrailing(SurfaceNode node) { + this.compactTrailing = node; + return this; + } + + /// Sets the Dynamic Island minimal region, shown when multiple activities compete for the + /// island. + /// + /// #### Parameters + /// + /// - `node`: the region content + /// + /// #### Returns + /// + /// this descriptor, for chaining + public LiveActivityDescriptor setMinimal(SurfaceNode node) { + this.minimal = node; + return this; + } + + /// Sets the leading region of the expanded (long-pressed) Dynamic Island. + /// + /// #### Parameters + /// + /// - `node`: the region content + /// + /// #### Returns + /// + /// this descriptor, for chaining + public LiveActivityDescriptor setExpandedLeading(SurfaceNode node) { + this.expandedLeading = node; + return this; + } + + /// Sets the trailing region of the expanded (long-pressed) Dynamic Island. + /// + /// #### Parameters + /// + /// - `node`: the region content + /// + /// #### Returns + /// + /// this descriptor, for chaining + public LiveActivityDescriptor setExpandedTrailing(SurfaceNode node) { + this.expandedTrailing = node; + return this; + } + + /// Sets the center region of the expanded (long-pressed) Dynamic Island. + /// + /// #### Parameters + /// + /// - `node`: the region content + /// + /// #### Returns + /// + /// this descriptor, for chaining + public LiveActivityDescriptor setExpandedCenter(SurfaceNode node) { + this.expandedCenter = node; + return this; + } + + /// Sets the bottom region of the expanded (long-pressed) Dynamic Island. + /// + /// #### Parameters + /// + /// - `node`: the region content + /// + /// #### Returns + /// + /// this descriptor, for chaining + public LiveActivityDescriptor setExpandedBottom(SurfaceNode node) { + this.expandedBottom = node; + return this; + } + + /// Sets the accent color of the activity's chrome (island key line, notification accent). + /// + /// #### Parameters + /// + /// - `tint`: the accent color + /// + /// #### Returns + /// + /// this descriptor, for chaining + public LiveActivityDescriptor setTint(SurfaceColor tint) { + this.tint = tint; + return this; + } + + /// Sets the Android notification channel the activity's ongoing notification posts to. When + /// unset a default `cn1_live_activities` channel is used. + /// + /// #### Parameters + /// + /// - `channelId`: the channel id + /// + /// #### Returns + /// + /// this descriptor, for chaining + public LiveActivityDescriptor setAndroidChannelId(String channelId) { + this.androidChannelId = channelId; + return this; + } + + /// Returns the app-defined activity type tag. + public String getActivityType() { + return activityType; + } + + /// Returns the primary presentation layout, or null. + public SurfaceNode getContent() { + return content; + } + + /// Returns the compact leading island region, or null. + public SurfaceNode getCompactLeading() { + return compactLeading; + } + + /// Returns the compact trailing island region, or null. + public SurfaceNode getCompactTrailing() { + return compactTrailing; + } + + /// Returns the minimal island region, or null. + public SurfaceNode getMinimal() { + return minimal; + } + + /// Returns the expanded leading island region, or null. + public SurfaceNode getExpandedLeading() { + return expandedLeading; + } + + /// Returns the expanded trailing island region, or null. + public SurfaceNode getExpandedTrailing() { + return expandedTrailing; + } + + /// Returns the expanded center island region, or null. + public SurfaceNode getExpandedCenter() { + return expandedCenter; + } + + /// Returns the expanded bottom island region, or null. + public SurfaceNode getExpandedBottom() { + return expandedBottom; + } + + /// Returns the accent color, or null. + public SurfaceColor getTint() { + return tint; + } + + /// Returns the Android notification channel id, or null for the default channel. + public String getAndroidChannelId() { + return androidChannelId; + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceActionEvent.java b/CodenameOne/src/com/codename1/surfaces/SurfaceActionEvent.java new file mode 100644 index 00000000000..ffc63d6978a --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceActionEvent.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import java.util.HashMap; +import java.util.Map; + +/// A user interaction with an external surface: the user tapped a node that carries an action id. +/// Because surfaces render outside the (possibly dead) app process, the tap first opens or +/// foregrounds the app and the framework then delivers this event to the handler registered with +/// `Surfaces.setActionHandler(...)` on the EDT. Events that arrive before a handler is registered +/// (typically because the tap launched the app) are queued and flagged as cold start. +public class SurfaceActionEvent { + private final String source; + private final String actionId; + private final Map params; + private boolean coldStart; + + /// Creates an action event. Framework/port use; apps only consume these. + /// + /// #### Parameters + /// + /// - `source`: the widget kind id or live activity type the tap came from + /// - `actionId`: the app-defined action id of the tapped node + /// - `params`: the action parameters, may be null + public SurfaceActionEvent(String source, String actionId, Map params) { + this.source = source; + this.actionId = actionId; + this.params = params == null ? new HashMap() : params; + } + + /// Returns the widget kind id or live activity type the tap came from. + public String getSource() { + return source; + } + + /// Returns the app-defined action id of the tapped node. + public String getActionId() { + return actionId; + } + + /// Returns the action parameters, never null. + public Map getParams() { + return params; + } + + /// Returns true when this event launched the app (it arrived before an action handler was + /// registered and was queued until registration). + public boolean isColdStart() { + return coldStart; + } + + void setColdStart(boolean coldStart) { + this.coldStart = coldStart; + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceActionHandler.java b/CodenameOne/src/com/codename1/surfaces/SurfaceActionHandler.java new file mode 100644 index 00000000000..4704c5a8580 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceActionHandler.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +/// Receives surface action events. Register a single handler with +/// `Surfaces.setActionHandler(...)` (your `init()` is the natural place so cold-start actions are +/// delivered as soon as possible). +public interface SurfaceActionHandler { + /// Invoked on the EDT when the user activated an action node on an external surface. + /// + /// #### Parameters + /// + /// - `evt`: the action event + void onSurfaceAction(SurfaceActionEvent evt); +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceAlignment.java b/CodenameOne/src/com/codename1/surfaces/SurfaceAlignment.java new file mode 100644 index 00000000000..fa5b8eb807c --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceAlignment.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +/// Nine-way alignment of a node within its parent. In a `SurfaceBox` (stacked children) this places +/// the child within the box; in a `SurfaceRow` or `SurfaceColumn` only the cross-axis component +/// applies. +public enum SurfaceAlignment { + TOP_LEADING("topLeading"), + TOP("top"), + TOP_TRAILING("topTrailing"), + LEADING("leading"), + CENTER("center"), + TRAILING("trailing"), + BOTTOM_LEADING("bottomLeading"), + BOTTOM("bottom"), + BOTTOM_TRAILING("bottomTrailing"); + + private final String jsonName; + + SurfaceAlignment(String jsonName) { + this.jsonName = jsonName; + } + + /// Returns the wire-format name used in the serialized descriptor. + public String getJsonName() { + return jsonName; + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceBox.java b/CodenameOne/src/com/codename1/surfaces/SurfaceBox.java new file mode 100644 index 00000000000..e2f378c3913 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceBox.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import java.util.Map; + +/// A container that stacks its children on top of each other. Each child positions itself within +/// the box via its own `setAlignment(...)`. Maps to a SwiftUI `ZStack`, an Android `FrameLayout` +/// and a layered box on desktop surfaces. +public class SurfaceBox extends SurfaceContainer { + @Override + public SurfaceBox add(SurfaceNode child) { + super.add(child); + return this; + } + + @Override + public SurfaceBox setPadding(int all) { + super.setPadding(all); + return this; + } + + @Override + public SurfaceBox setPadding(int top, int right, int bottom, int left) { + super.setPadding(top, right, bottom, left); + return this; + } + + @Override + public SurfaceBox setBackground(SurfaceColor color) { + super.setBackground(color); + return this; + } + + @Override + public SurfaceBox setCornerRadius(int radius) { + super.setCornerRadius(radius); + return this; + } + + @Override + public SurfaceBox setAlignment(SurfaceAlignment alignment) { + super.setAlignment(alignment); + return this; + } + + @Override + public SurfaceBox setWeight(int weight) { + super.setWeight(weight); + return this; + } + + @Override + public SurfaceBox setSize(int widthDips, int heightDips) { + super.setSize(widthDips, heightDips); + return this; + } + + @Override + public SurfaceBox setAction(String actionId) { + super.setAction(actionId); + return this; + } + + @Override + public SurfaceBox setAction(String actionId, Map params) { + super.setAction(actionId, params); + return this; + } + + @Override + String getType() { + return "box"; + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceColor.java b/CodenameOne/src/com/codename1/surfaces/SurfaceColor.java new file mode 100644 index 00000000000..51bb3b8f9ba --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceColor.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +/// A color used on an external surface. Because surfaces render outside the app (potentially while +/// the app process is dead) they cannot resolve theme constants at render time, so a surface color +/// is either an explicit ARGB value with an optional dark-mode counterpart, or a semantic role the +/// operating system resolves natively (label, secondary label, background, accent). +/// +/// ```java +/// SurfaceColor.rgb(0xff333333, 0xffeeeeee) // dark text in light mode, light text in dark mode +/// SurfaceColor.LABEL // whatever the OS considers primary label color +/// ``` +public final class SurfaceColor { + /// The platform's primary label (body text) color. + public static final SurfaceColor LABEL = new SurfaceColor("label"); + + /// The platform's secondary (dimmed) label color. + public static final SurfaceColor SECONDARY_LABEL = new SurfaceColor("secondaryLabel"); + + /// The platform's standard surface background color. + public static final SurfaceColor BACKGROUND = new SurfaceColor("background"); + + /// The platform accent / tint color. + public static final SurfaceColor ACCENT = new SurfaceColor("accent"); + + private final int light; + private final int dark; + private final String role; + + private SurfaceColor(int light, int dark) { + this.light = light; + this.dark = dark; + this.role = null; + } + + private SurfaceColor(String role) { + this.light = 0; + this.dark = 0; + this.role = role; + } + + /// Creates a color used in both light and dark appearance. The alpha byte is honored + /// verbatim by every renderer: pass `0xFFRRGGBB` for an opaque color -- a value with a zero + /// alpha byte (e.g. a plain `0xRRGGBB` literal) is fully transparent and draws nothing. + /// + /// #### Parameters + /// + /// - `argb`: the color as `0xAARRGGBB` + /// + /// #### Returns + /// + /// the color + public static SurfaceColor rgb(int argb) { + return new SurfaceColor(argb, argb); + } + + /// Creates a color with distinct light and dark appearance values. The surface renderer picks + /// the value matching the system appearance. The alpha byte is honored verbatim: pass + /// `0xFFRRGGBB` for opaque colors. + /// + /// #### Parameters + /// + /// - `lightArgb`: the color used in light mode as `0xAARRGGBB` + /// - `darkArgb`: the color used in dark mode as `0xAARRGGBB` + /// + /// #### Returns + /// + /// the color + public static SurfaceColor rgb(int lightArgb, int darkArgb) { + return new SurfaceColor(lightArgb, darkArgb); + } + + /// Returns the light-mode ARGB value; meaningless for role colors. + public int getLight() { + return light; + } + + /// Returns the dark-mode ARGB value; meaningless for role colors. + public int getDark() { + return dark; + } + + /// Returns the semantic role name (`label`, `secondaryLabel`, `background`, `accent`), or null + /// for explicit ARGB colors. + public String getRole() { + return role; + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceColumn.java b/CodenameOne/src/com/codename1/surfaces/SurfaceColumn.java new file mode 100644 index 00000000000..634c9bbbae7 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceColumn.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import java.util.Map; + +/// A container that stacks its children vertically. Maps to a SwiftUI `VStack`, an Android +/// vertical `LinearLayout` and a vertical box on desktop surfaces. +public class SurfaceColumn extends SurfaceContainer { + private int spacing; + + /// Sets the vertical gap between consecutive children. + /// + /// #### Parameters + /// + /// - `spacing`: the gap in dips + /// + /// #### Returns + /// + /// this column, for chaining + public SurfaceColumn setSpacing(int spacing) { + this.spacing = spacing; + return this; + } + + /// Returns the gap between consecutive children in dips. + public int getSpacing() { + return spacing; + } + + @Override + public SurfaceColumn add(SurfaceNode child) { + super.add(child); + return this; + } + + @Override + public SurfaceColumn setPadding(int all) { + super.setPadding(all); + return this; + } + + @Override + public SurfaceColumn setPadding(int top, int right, int bottom, int left) { + super.setPadding(top, right, bottom, left); + return this; + } + + @Override + public SurfaceColumn setBackground(SurfaceColor color) { + super.setBackground(color); + return this; + } + + @Override + public SurfaceColumn setCornerRadius(int radius) { + super.setCornerRadius(radius); + return this; + } + + @Override + public SurfaceColumn setAlignment(SurfaceAlignment alignment) { + super.setAlignment(alignment); + return this; + } + + @Override + public SurfaceColumn setWeight(int weight) { + super.setWeight(weight); + return this; + } + + @Override + public SurfaceColumn setSize(int widthDips, int heightDips) { + super.setSize(widthDips, heightDips); + return this; + } + + @Override + public SurfaceColumn setAction(String actionId) { + super.setAction(actionId); + return this; + } + + @Override + public SurfaceColumn setAction(String actionId, Map params) { + super.setAction(actionId, params); + return this; + } + + @Override + String getType() { + return "col"; + } + + @Override + void serializeContent(Map out, Map images, int depth) { + if (spacing != 0) { + out.put("spacing", Integer.valueOf(spacing)); + } + super.serializeContent(out, images, depth); + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceContainer.java b/CodenameOne/src/com/codename1/surfaces/SurfaceContainer.java new file mode 100644 index 00000000000..2a54a50dcfe --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceContainer.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. + */ +package com.codename1.surfaces; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// The base class of surface nodes that hold children: `SurfaceColumn`, `SurfaceRow` and +/// `SurfaceBox`. Descriptors are limited to 8 nesting levels so they stay within the memory and +/// transaction budgets of platform widget renderers. +public abstract class SurfaceContainer extends SurfaceNode { + private final List children = new ArrayList(); + + /// Adds a child node. + /// + /// #### Parameters + /// + /// - `child`: the node to append + /// + /// #### Returns + /// + /// this container, for chaining + public SurfaceContainer add(SurfaceNode child) { + if (child != null) { + children.add(child); + } + return this; + } + + /// Returns the live list of children. + public List getChildren() { + return children; + } + + @Override + void serializeContent(Map out, Map images, int depth) { + List ch = new ArrayList(children.size()); + for (SurfaceNode child : children) { + ch.add(child.toMap(images, depth + 1)); + } + out.put("ch", ch); + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceDynamicText.java b/CodenameOne/src/com/codename1/surfaces/SurfaceDynamicText.java new file mode 100644 index 00000000000..8f3be35b55b --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceDynamicText.java @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import java.util.Date; +import java.util.Map; + +/// A date-driven text node the operating system animates natively -- the headline feature for +/// timers, delivery ETAs and elapsed-time displays: a countdown keeps ticking every second on the +/// widget or Dynamic Island even though the app process is not running. +/// +/// ```java +/// // counts down to the ETA the OS-native way, no app wakeups needed +/// new SurfaceDynamicText(SurfaceDynamicText.STYLE_TIMER_DOWN, "eta") +/// .setFontSize(22).setFontWeight(SurfaceFontWeight.BOLD) +/// ``` +/// +/// The target date is either fixed at build time or referenced from the state map by key (the +/// state value is the epoch time in milliseconds as a `Long`), so a live-activity update can move +/// the ETA without republishing the layout. +/// +/// Rendering: iOS uses `Text(date, style:)`, Android uses `Chronometer` / `TextClock`. On Android +/// the `STYLE_DATE` and `STYLE_RELATIVE` styles are approximated -- they render as static text +/// computed when the widget last refreshed. +public class SurfaceDynamicText extends SurfaceNode { + /// Counts down to the target date, e.g. `04:59`. Rendered by `Text(date, style: .timer)` on + /// iOS and a countdown `Chronometer` on Android. + public static final int STYLE_TIMER_DOWN = 0; + + /// Counts up since the target date, e.g. `12:07`. + public static final int STYLE_TIMER_UP = 1; + + /// The target date's clock time, e.g. `9:41 AM`. + public static final int STYLE_TIME = 2; + + /// The target date's calendar date, e.g. `June 3`. Approximated as static text on Android. + public static final int STYLE_DATE = 3; + + /// The distance to the target date in words, e.g. `in 5 min`. Approximated as static text on + /// Android. + public static final int STYLE_RELATIVE = 4; + + private final int style; + private final Date date; + private final String dateKey; + private int fontSize; + private SurfaceFontWeight fontWeight; + private SurfaceColor color; + + /// Creates a dynamic text node with a fixed target date. + /// + /// #### Parameters + /// + /// - `style`: one of the `STYLE_...` constants + /// - `date`: the target date + public SurfaceDynamicText(int style, Date date) { + this.style = style; + this.date = date; + this.dateKey = null; + } + + /// Creates a dynamic text node whose target date comes from the state map. The state value is + /// the epoch time in milliseconds as a `Long`. + /// + /// #### Parameters + /// + /// - `style`: one of the `STYLE_...` constants + /// - `dateStateKey`: the state-map key holding the epoch millis + public SurfaceDynamicText(int style, String dateStateKey) { + this.style = style; + this.date = null; + this.dateKey = dateStateKey; + } + + /// Sets the font size. + /// + /// #### Parameters + /// + /// - `fontSize`: the size in dips + /// + /// #### Returns + /// + /// this node, for chaining + public SurfaceDynamicText setFontSize(int fontSize) { + this.fontSize = fontSize; + return this; + } + + /// Sets the font weight. + /// + /// #### Parameters + /// + /// - `fontWeight`: the weight + /// + /// #### Returns + /// + /// this node, for chaining + public SurfaceDynamicText setFontWeight(SurfaceFontWeight fontWeight) { + this.fontWeight = fontWeight; + return this; + } + + /// Sets the text color. + /// + /// #### Parameters + /// + /// - `color`: the color + /// + /// #### Returns + /// + /// this node, for chaining + public SurfaceDynamicText setColor(SurfaceColor color) { + this.color = color; + return this; + } + + /// Returns the `STYLE_...` constant of this node. + public int getStyle() { + return style; + } + + /// Returns the fixed target date, or null when the date comes from the state map. + public Date getDate() { + return date; + } + + /// Returns the state-map key of the target date, or null when the date is fixed. + public String getDateKey() { + return dateKey; + } + + /// Returns the font size in dips, 0 for the platform default. + public int getFontSize() { + return fontSize; + } + + /// Returns the font weight, or null for the platform default. + public SurfaceFontWeight getFontWeight() { + return fontWeight; + } + + /// Returns the text color, or null for the platform default. + public SurfaceColor getColor() { + return color; + } + + @Override + public SurfaceDynamicText setPadding(int all) { + super.setPadding(all); + return this; + } + + @Override + public SurfaceDynamicText setPadding(int top, int right, int bottom, int left) { + super.setPadding(top, right, bottom, left); + return this; + } + + @Override + public SurfaceDynamicText setBackground(SurfaceColor background) { + super.setBackground(background); + return this; + } + + @Override + public SurfaceDynamicText setCornerRadius(int radius) { + super.setCornerRadius(radius); + return this; + } + + @Override + public SurfaceDynamicText setAlignment(SurfaceAlignment alignment) { + super.setAlignment(alignment); + return this; + } + + @Override + public SurfaceDynamicText setWeight(int weight) { + super.setWeight(weight); + return this; + } + + @Override + public SurfaceDynamicText setSize(int widthDips, int heightDips) { + super.setSize(widthDips, heightDips); + return this; + } + + @Override + public SurfaceDynamicText setAction(String actionId) { + super.setAction(actionId); + return this; + } + + @Override + public SurfaceDynamicText setAction(String actionId, Map params) { + super.setAction(actionId, params); + return this; + } + + @Override + String getType() { + return "dyn"; + } + + @Override + void serializeContent(Map out, Map images, int depth) { + String styleName; + switch (style) { + case STYLE_TIMER_UP: + styleName = "timerUp"; + break; + case STYLE_TIME: + styleName = "time"; + break; + case STYLE_DATE: + styleName = "date"; + break; + case STYLE_RELATIVE: + styleName = "relative"; + break; + default: + styleName = "timerDown"; + } + out.put("style", styleName); + if (dateKey != null) { + out.put("dateKey", dateKey); + } else if (date != null) { + out.put("date", Long.valueOf(date.getTime())); + } + if (fontSize != 0) { + out.put("size", Integer.valueOf(fontSize)); + } + if (fontWeight != null) { + out.put("fw", fontWeight.getJsonName()); + } + if (color != null) { + out.put("color", SurfaceSerializer.colorMap(color)); + } + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceFontWeight.java b/CodenameOne/src/com/codename1/surfaces/SurfaceFontWeight.java new file mode 100644 index 00000000000..4214e2fc35f --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceFontWeight.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.surfaces; + +/// Font weight of a surface text node. Platforms with a coarser weight scale round to the nearest +/// supported weight; Android app widgets render `LIGHT`/`REGULAR`/`MEDIUM` as regular and +/// `SEMIBOLD`/`BOLD` as bold. +public enum SurfaceFontWeight { + LIGHT("light"), + REGULAR("regular"), + MEDIUM("medium"), + SEMIBOLD("semibold"), + BOLD("bold"); + + private final String jsonName; + + SurfaceFontWeight(String jsonName) { + this.jsonName = jsonName; + } + + /// Returns the wire-format name used in the serialized descriptor. + public String getJsonName() { + return jsonName; + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceImage.java b/CodenameOne/src/com/codename1/surfaces/SurfaceImage.java new file mode 100644 index 00000000000..c319373beb4 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceImage.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import com.codename1.ui.Image; + +import java.util.Map; + +/// An image node. Because surfaces render while the app process may be dead, images are shipped as +/// named PNG blobs beside the descriptor rather than passed by reference: constructing the node +/// from a `com.codename1.ui.Image` encodes it to PNG at publish time and names it by content hash +/// (identical art re-published later ships no new bytes). A node can also reference a name that +/// was already shipped with a previous publish. +/// +/// Keep surface art small -- widget renderers run under tight memory budgets (about 30mb for the +/// whole iOS widget extension) and Android parcels the rendered widget over a 1mb binder +/// transaction. +public class SurfaceImage extends SurfaceNode { + /// Scales the image to fit inside the node bounds, preserving aspect ratio. + public static final int SCALE_FIT = 0; + + /// Scales the image to fill the node bounds, preserving aspect ratio and cropping overflow. + public static final int SCALE_FILL = 1; + + /// Centers the image without scaling. + public static final int SCALE_CENTER = 2; + + private final Image image; + private final String registeredName; + private int scaleMode = SCALE_FIT; + private SurfaceColor tint; + + /// Creates an image node from an image. The image is encoded to PNG when the descriptor is + /// published. + /// + /// #### Parameters + /// + /// - `image`: the image to ship with the descriptor + public SurfaceImage(Image image) { + this.image = image; + this.registeredName = null; + } + + /// Creates an image node referencing an image name shipped with an earlier publish to the same + /// surface. + /// + /// #### Parameters + /// + /// - `registeredName`: the previously registered image name + public SurfaceImage(String registeredName) { + this.image = null; + this.registeredName = registeredName; + } + + /// Sets how the image scales within the node bounds. + /// + /// #### Parameters + /// + /// - `scaleMode`: one of `SCALE_FIT`, `SCALE_FILL`, `SCALE_CENTER` + /// + /// #### Returns + /// + /// this image node, for chaining + public SurfaceImage setScaleMode(int scaleMode) { + this.scaleMode = scaleMode; + return this; + } + + /// Tints the image with the supplied color, template-image style: the image's alpha channel is + /// kept and its color replaced. + /// + /// #### Parameters + /// + /// - `tint`: the tint color + /// + /// #### Returns + /// + /// this image node, for chaining + public SurfaceImage setTint(SurfaceColor tint) { + this.tint = tint; + return this; + } + + /// Returns the source image, or null when this node references a registered name. + public Image getImage() { + return image; + } + + /// Returns the referenced registered name, or null when this node ships its own image. + public String getRegisteredName() { + return registeredName; + } + + /// Returns the scale mode, one of `SCALE_FIT`, `SCALE_FILL`, `SCALE_CENTER`. + public int getScaleMode() { + return scaleMode; + } + + /// Returns the tint color, or null. + public SurfaceColor getTint() { + return tint; + } + + @Override + public SurfaceImage setPadding(int all) { + super.setPadding(all); + return this; + } + + @Override + public SurfaceImage setPadding(int top, int right, int bottom, int left) { + super.setPadding(top, right, bottom, left); + return this; + } + + @Override + public SurfaceImage setBackground(SurfaceColor background) { + super.setBackground(background); + return this; + } + + @Override + public SurfaceImage setCornerRadius(int radius) { + super.setCornerRadius(radius); + return this; + } + + @Override + public SurfaceImage setAlignment(SurfaceAlignment alignment) { + super.setAlignment(alignment); + return this; + } + + @Override + public SurfaceImage setWeight(int weight) { + super.setWeight(weight); + return this; + } + + @Override + public SurfaceImage setSize(int widthDips, int heightDips) { + super.setSize(widthDips, heightDips); + return this; + } + + @Override + public SurfaceImage setAction(String actionId) { + super.setAction(actionId); + return this; + } + + @Override + public SurfaceImage setAction(String actionId, Map params) { + super.setAction(actionId, params); + return this; + } + + @Override + String getType() { + return "img"; + } + + @Override + void serializeContent(Map out, Map images, int depth) { + String name = registeredName; + if (name == null) { + name = SurfaceSerializer.registerImage(image, images); + } + out.put("name", name == null ? "" : name); + if (scaleMode == SCALE_FILL) { + out.put("scale", "fill"); + } else if (scaleMode == SCALE_CENTER) { + out.put("scale", "center"); + } + if (tint != null) { + out.put("tint", SurfaceSerializer.colorMap(tint)); + } + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceNode.java b/CodenameOne/src/com/codename1/surfaces/SurfaceNode.java new file mode 100644 index 00000000000..780c4ef8872 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceNode.java @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import java.util.LinkedHashMap; +import java.util.Map; + +/// The base class of all surface layout nodes. A surface descriptor is a small tree of nodes +/// (columns, rows, boxes, text, images, progress indicators) that every platform can render +/// natively while the app process is not running -- so a node is pure data: there are no +/// listeners, no theme lookups and no live `Image` references in the serialized form. +/// +/// All nodes share the styling in this class: padding, background color, corner radius, alignment +/// within the parent, flexible weight, an optional fixed size and an optional action. An action is +/// a plain string id (plus optional parameters) delivered to the handler registered with +/// `Surfaces.setActionHandler(...)` when the user taps the node -- see `SurfaceActionEvent`. +/// +/// All dimensions are in display-independent pixels (dips). +public abstract class SurfaceNode { + static final int MAX_DEPTH = 8; + + private int paddingTop; + private int paddingRight; + private int paddingBottom; + private int paddingLeft; + private SurfaceColor background; + private int cornerRadius; + private SurfaceAlignment alignment; + private int weight; + private int widthDips; + private int heightDips; + private String actionId; + private Map actionParams; + + /// Sets the same padding on all four sides. + /// + /// #### Parameters + /// + /// - `all`: padding in dips + /// + /// #### Returns + /// + /// this node, for chaining + public SurfaceNode setPadding(int all) { + return setPadding(all, all, all, all); + } + + /// Sets the padding of each side individually. + /// + /// #### Parameters + /// + /// - `top`: top padding in dips + /// - `right`: right padding in dips + /// - `bottom`: bottom padding in dips + /// - `left`: left padding in dips + /// + /// #### Returns + /// + /// this node, for chaining + public SurfaceNode setPadding(int top, int right, int bottom, int left) { + this.paddingTop = top; + this.paddingRight = right; + this.paddingBottom = bottom; + this.paddingLeft = left; + return this; + } + + /// Sets the background color of this node. + /// + /// #### Parameters + /// + /// - `color`: the background color + /// + /// #### Returns + /// + /// this node, for chaining + public SurfaceNode setBackground(SurfaceColor color) { + this.background = color; + return this; + } + + /// Sets the corner radius applied to the node's background. May render square on Android + /// versions below 12. + /// + /// #### Parameters + /// + /// - `radius`: the corner radius in dips + /// + /// #### Returns + /// + /// this node, for chaining + public SurfaceNode setCornerRadius(int radius) { + this.cornerRadius = radius; + return this; + } + + /// Sets this node's alignment within its parent. In a `SurfaceBox` all nine positions apply; + /// in rows and columns only the cross-axis component is used. + /// + /// #### Parameters + /// + /// - `alignment`: the alignment + /// + /// #### Returns + /// + /// this node, for chaining + public SurfaceNode setAlignment(SurfaceAlignment alignment) { + this.alignment = alignment; + return this; + } + + /// Sets the flexible-space weight of this node within a row or column. Nodes with a weight + /// share the leftover space of the parent proportionally; a weight of 0 (the default) sizes + /// the node to its natural size. + /// + /// #### Parameters + /// + /// - `weight`: the relative weight, 0 for natural sizing + /// + /// #### Returns + /// + /// this node, for chaining + public SurfaceNode setWeight(int weight) { + this.weight = weight; + return this; + } + + /// Sets a fixed size for this node. A value of 0 (the default) keeps the natural size of the + /// respective axis. + /// + /// #### Parameters + /// + /// - `widthDips`: fixed width in dips, 0 for natural width + /// - `heightDips`: fixed height in dips, 0 for natural height + /// + /// #### Returns + /// + /// this node, for chaining + public SurfaceNode setSize(int widthDips, int heightDips) { + this.widthDips = widthDips; + this.heightDips = heightDips; + return this; + } + + /// Assigns a tap action to this node. Tapping the node opens (or foregrounds) the app and + /// delivers the action id to the handler registered with `Surfaces.setActionHandler(...)`. + /// Note that small iOS home-screen widgets only honor the action of the root node. + /// + /// #### Parameters + /// + /// - `actionId`: the app-defined action identifier + /// + /// #### Returns + /// + /// this node, for chaining + public SurfaceNode setAction(String actionId) { + return setAction(actionId, null); + } + + /// Assigns a tap action with parameters to this node. The parameter map may contain `String`, + /// `Number` and `Boolean` values and is delivered verbatim with the `SurfaceActionEvent`. + /// + /// #### Parameters + /// + /// - `actionId`: the app-defined action identifier + /// - `params`: parameters delivered with the action, may be null + /// + /// #### Returns + /// + /// this node, for chaining + public SurfaceNode setAction(String actionId, Map params) { + this.actionId = actionId; + this.actionParams = params; + return this; + } + + /// Returns the top padding in dips. + public int getPaddingTop() { + return paddingTop; + } + + /// Returns the right padding in dips. + public int getPaddingRight() { + return paddingRight; + } + + /// Returns the bottom padding in dips. + public int getPaddingBottom() { + return paddingBottom; + } + + /// Returns the left padding in dips. + public int getPaddingLeft() { + return paddingLeft; + } + + /// Returns the background color, or null. + public SurfaceColor getBackground() { + return background; + } + + /// Returns the corner radius in dips. + public int getCornerRadius() { + return cornerRadius; + } + + /// Returns the alignment within the parent, or null for the platform default. + public SurfaceAlignment getAlignment() { + return alignment; + } + + /// Returns the flexible-space weight, 0 when naturally sized. + public int getWeight() { + return weight; + } + + /// Returns the fixed width in dips, 0 when naturally sized. + public int getWidthDips() { + return widthDips; + } + + /// Returns the fixed height in dips, 0 when naturally sized. + public int getHeightDips() { + return heightDips; + } + + /// Returns the tap action id, or null. + public String getActionId() { + return actionId; + } + + /// Returns the tap action parameters, or null. + public Map getActionParams() { + return actionParams; + } + + /// Returns the wire-format type tag of this node (`col`, `row`, `box`, `text`, `dyn`, `img`, + /// `prog`, `vec`, `spacer`). + abstract String getType(); + + /// Adds the node-specific keys to the serialized form. Image nodes register their PNG bytes + /// in `images`; containers serialize their children at `depth + 1`. + abstract void serializeContent(Map out, Map images, int depth); + + /// Serializes this node (and its subtree) to the map structure emitted as JSON. + final Map toMap(Map images, int depth) { + if (depth > MAX_DEPTH) { + throw new IllegalArgumentException("Surface descriptors are limited to " + MAX_DEPTH + + " nesting levels so they stay within platform widget budgets"); + } + Map out = new LinkedHashMap(); + out.put("t", getType()); + if (paddingTop != 0 || paddingRight != 0 || paddingBottom != 0 || paddingLeft != 0) { + java.util.List pad = new java.util.ArrayList(4); + pad.add(Integer.valueOf(paddingTop)); + pad.add(Integer.valueOf(paddingRight)); + pad.add(Integer.valueOf(paddingBottom)); + pad.add(Integer.valueOf(paddingLeft)); + out.put("pad", pad); + } + if (background != null) { + out.put("bg", SurfaceSerializer.colorMap(background)); + } + if (cornerRadius != 0) { + out.put("corner", Integer.valueOf(cornerRadius)); + } + if (alignment != null) { + out.put("align", alignment.getJsonName()); + } + if (weight != 0) { + out.put("weight", Integer.valueOf(weight)); + } + if (widthDips != 0) { + out.put("w", Integer.valueOf(widthDips)); + } + if (heightDips != 0) { + out.put("h", Integer.valueOf(heightDips)); + } + if (actionId != null) { + Map action = new LinkedHashMap(); + action.put("id", actionId); + if (actionParams != null && !actionParams.isEmpty()) { + action.put("p", SurfaceSerializer.sortedCopy(actionParams)); + } + out.put("action", action); + } + serializeContent(out, images, depth); + return out; + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceProgress.java b/CodenameOne/src/com/codename1/surfaces/SurfaceProgress.java new file mode 100644 index 00000000000..7531966e6de --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceProgress.java @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import java.util.Date; +import java.util.Map; + +/// A progress indicator node. The value is a fraction between 0 and 1, supplied either literally, +/// by state-map key, or as a date interval the OS animates natively (iOS only; other platforms +/// freeze the interval's value as of the last refresh). +/// +/// Determinate `STYLE_CIRCULAR` progress falls back to a linear bar on Android app widgets, which +/// only support indeterminate circular spinners. +public class SurfaceProgress extends SurfaceNode { + /// A horizontal progress bar. + public static final int STYLE_LINEAR = 0; + + /// A circular gauge. Falls back to linear on Android app widgets. + public static final int STYLE_CIRCULAR = 1; + + private final int style; + private float value = -1; + private String valueKey; + private Date intervalStart; + private Date intervalEnd; + private SurfaceColor color; + + /// Creates a progress node. + /// + /// #### Parameters + /// + /// - `style`: `STYLE_LINEAR` or `STYLE_CIRCULAR` + public SurfaceProgress(int style) { + this.style = style; + } + + /// Sets a literal progress value. + /// + /// #### Parameters + /// + /// - `value`: the progress fraction between 0 and 1 + /// + /// #### Returns + /// + /// this progress node, for chaining + public SurfaceProgress setValue(float value) { + this.value = value; + return this; + } + + /// Reads the progress value from the state map. The state value is a `Number` between 0 and 1. + /// + /// #### Parameters + /// + /// - `valueStateKey`: the state-map key holding the fraction + /// + /// #### Returns + /// + /// this progress node, for chaining + public SurfaceProgress setValueState(String valueStateKey) { + this.valueKey = valueStateKey; + return this; + } + + /// Animates progress across a date interval on the OS clock without app wakeups. Only iOS + /// renders this natively; other platforms display the interval's fraction as of their last + /// refresh. + /// + /// #### Parameters + /// + /// - `start`: interval start + /// - `end`: interval end + /// + /// #### Returns + /// + /// this progress node, for chaining + public SurfaceProgress setDateInterval(Date start, Date end) { + this.intervalStart = start; + this.intervalEnd = end; + return this; + } + + /// Sets the indicator color. + /// + /// #### Parameters + /// + /// - `color`: the color + /// + /// #### Returns + /// + /// this progress node, for chaining + public SurfaceProgress setColor(SurfaceColor color) { + this.color = color; + return this; + } + + /// Returns `STYLE_LINEAR` or `STYLE_CIRCULAR`. + public int getStyle() { + return style; + } + + /// Returns the literal progress fraction, or -1 when unset. + public float getValue() { + return value; + } + + /// Returns the state-map key of the progress value, or null. + public String getValueKey() { + return valueKey; + } + + /// Returns the animated interval start, or null. + public Date getIntervalStart() { + return intervalStart; + } + + /// Returns the animated interval end, or null. + public Date getIntervalEnd() { + return intervalEnd; + } + + /// Returns the indicator color, or null for the platform default. + public SurfaceColor getColor() { + return color; + } + + @Override + public SurfaceProgress setPadding(int all) { + super.setPadding(all); + return this; + } + + @Override + public SurfaceProgress setPadding(int top, int right, int bottom, int left) { + super.setPadding(top, right, bottom, left); + return this; + } + + @Override + public SurfaceProgress setBackground(SurfaceColor background) { + super.setBackground(background); + return this; + } + + @Override + public SurfaceProgress setCornerRadius(int radius) { + super.setCornerRadius(radius); + return this; + } + + @Override + public SurfaceProgress setAlignment(SurfaceAlignment alignment) { + super.setAlignment(alignment); + return this; + } + + @Override + public SurfaceProgress setWeight(int weight) { + super.setWeight(weight); + return this; + } + + @Override + public SurfaceProgress setSize(int widthDips, int heightDips) { + super.setSize(widthDips, heightDips); + return this; + } + + @Override + public SurfaceProgress setAction(String actionId) { + super.setAction(actionId); + return this; + } + + @Override + public SurfaceProgress setAction(String actionId, Map params) { + super.setAction(actionId, params); + return this; + } + + @Override + String getType() { + return "prog"; + } + + @Override + void serializeContent(Map out, Map images, int depth) { + if (style == STYLE_CIRCULAR) { + out.put("style", "circular"); + } else { + out.put("style", "linear"); + } + if (valueKey != null) { + out.put("valueKey", valueKey); + } else if (intervalStart != null && intervalEnd != null) { + out.put("start", Long.valueOf(intervalStart.getTime())); + out.put("end", Long.valueOf(intervalEnd.getTime())); + } else if (value >= 0) { + out.put("value", Double.valueOf(value)); + } + if (color != null) { + out.put("color", SurfaceSerializer.colorMap(color)); + } + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceRasterizer.java b/CodenameOne/src/com/codename1/surfaces/SurfaceRasterizer.java new file mode 100644 index 00000000000..fab9e846308 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceRasterizer.java @@ -0,0 +1,1275 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import com.codename1.io.Log; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.ui.Stroke; +import com.codename1.ui.geom.GeneralPath; + +import java.io.ByteArrayInputStream; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A shared software renderer for desktop ports (the JavaSE simulator, the native Windows and +/// Linux ports): draws a parsed surface descriptor node into a mutable Codename One `Image` using +/// only `Graphics` primitives -- no `Container`/`Label` components and no theme dependence, so the +/// output looks the same on every desktop surface. Must run on the EDT (it creates images and +/// measures fonts). +/// +/// The input is the wire format produced by `SurfaceSerializer` parsed back with +/// `com.codename1.io.JSONParser` -- numbers may arrive as `Double` or `Long`, both are handled. +/// Alongside the pixels the rasterizer returns the absolute hit rectangles of every node carrying +/// an `action`, plus the epoch time when a re-render is due for time-driven content (countdowns, +/// clocks, date-interval progress). +public final class SurfaceRasterizer { + /// Rendering fidelity note: image `tint` is intentionally not applied by this preview + /// rasterizer -- template-image tinting requires per-pixel masking that core `Graphics` + /// primitives do not offer cheaply. The image renders untinted, which is acceptable preview + /// fidelity for desktop surfaces; mobile platforms tint natively. + private static final int ROLE_LABEL_LIGHT = 0xff1c1c1e; + private static final int ROLE_LABEL_DARK = 0xffffffff; + // solid approximations of the translucent iOS secondary label colors, avoiding + // alpha-blend complexity in the preview + private static final int ROLE_SECONDARY_LIGHT = 0xff6c6c70; + private static final int ROLE_SECONDARY_DARK = 0xffaeaeb2; + private static final int ROLE_BACKGROUND_LIGHT = 0xffffffff; + private static final int ROLE_BACKGROUND_DARK = 0xff1c1c1e; + private static final int ROLE_ACCENT = 0xff007aff; + + private static final int DEFAULT_FONT_SIZE = 14; + private static final long MINUTE_MILLIS = 60000L; + private static final String[] MONTHS = { + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" + }; + + /// Decoded PNGs keyed by their content-hash wire name; the name uniquely identifies the bytes + /// so entries never go stale. Trimmed when it grows past a small cap. + private static final Map decodedImages = new HashMap(); + private static final int DECODED_IMAGE_CACHE_CAP = 48; + + private SurfaceRasterizer() { + } + + /// The absolute bounds of a node that carries a tap action, in pixels of the rasterized image. + public static class ActionRect { + private final int x; + private final int y; + private final int width; + private final int height; + private final String actionId; + private final Map params; + + ActionRect(int x, int y, int width, int height, String actionId, + Map params) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.actionId = actionId; + this.params = params; + } + + /// Returns the left edge in pixels. + public int getX() { + return x; + } + + /// Returns the top edge in pixels. + public int getY() { + return y; + } + + /// Returns the width in pixels. + public int getWidth() { + return width; + } + + /// Returns the height in pixels. + public int getHeight() { + return height; + } + + /// Returns the app-defined action id. + public String getActionId() { + return actionId; + } + + /// Returns the action parameters, or null. + public Map getParams() { + return params; + } + } + + /// The output of a rasterization pass: pixels, action hit rectangles and the next re-render + /// deadline. + public static class Result { + private final Image image; + private final int[] argb; + private final List actions; + private final long nextTickMillis; + + Result(Image image, int[] argb, List actions, long nextTickMillis) { + this.image = image; + this.argb = argb; + this.actions = actions; + this.nextTickMillis = nextTickMillis; + } + + /// Returns the rasterized image. + public Image getImage() { + return image; + } + + /// Returns the width*height ARGB pixels of the rasterized image. + public int[] getArgb() { + return argb; + } + + /// Returns the hit rectangles of every node that carries an action, in image pixels. + public List getActions() { + return actions; + } + + /// Returns 0 when the content is static; otherwise the epoch millis when a re-render is + /// due -- a one second cadence while a timer countdown or a date-interval progress is + /// visible, the next minute boundary for clock and relative text. + public long getNextTickMillis() { + return nextTickMillis; + } + } + + /// Rasterizes a descriptor node into a transparent image of the requested size. Must run on + /// the EDT. + /// + /// #### Parameters + /// + /// - `node`: a parsed descriptor node map as produced by the serializer and parsed back with + /// `com.codename1.io.JSONParser` + /// - `state`: the current entry's state map, may be null + /// - `images`: PNG blobs keyed by wire name, may be null + /// - `width`: target width in pixels + /// - `height`: target height in pixels + /// - `dark`: true to resolve dark-mode colors + /// - `now`: the current epoch millis used for dynamic text and interval progress + /// + /// #### Returns + /// + /// the rasterization result + public static Result rasterize(Map node, Map state, + Map images, int width, int height, boolean dark, long now) { + Image target = Image.createImage(width, height, 0); + List actions = new ArrayList(); + long[] tick = new long[1]; + if (node != null) { + LNode root = build(node); + measure(root, state, images, now); + arrange(root, 0, 0, width, height); + Graphics g = target.getGraphics(); + draw(g, root, state, images, dark, now, actions, tick); + } + return new Result(target, target.getRGB(), actions, tick[0]); + } + + // --- timeline helpers shared by all desktop ports ------------------------- + + /// Returns the timeline entry whose date most recently passed, or the first entry when none + /// passed yet, or null for an entry-less document. + /// + /// #### Parameters + /// + /// - `timelineDoc`: a parsed timeline JSON document + /// - `now`: the current epoch millis + /// + /// #### Returns + /// + /// the active entry map (`date`, `state`), or null + public static Map currentEntry(Map timelineDoc, long now) { + List> entries = entriesOf(timelineDoc); + if (entries.isEmpty()) { + return null; + } + Map current = null; + for (Map e : entries) { + if (asLong(e.get("date"), 0) <= now) { + current = e; + } + } + if (current == null) { + current = entries.get(0); + } + return current; + } + + /// Returns the epoch millis of the next entry flip after `now`, or 0 when no future entry + /// exists. + /// + /// #### Parameters + /// + /// - `timelineDoc`: a parsed timeline JSON document + /// - `now`: the current epoch millis + /// + /// #### Returns + /// + /// the next flip time, or 0 + public static long nextEntryFlip(Map timelineDoc, long now) { + long next = 0; + for (Map e : entriesOf(timelineDoc)) { + long date = asLong(e.get("date"), 0); + if (date > now && (next == 0 || date < next)) { + next = date; + } + } + return next; + } + + /// Picks the layout of a timeline document for a size name (`small` / `medium` / `large` / + /// `lockscreen`): the explicit per-size layout when present, else the `default` layout, else + /// null. + /// + /// #### Parameters + /// + /// - `timelineDoc`: a parsed timeline JSON document + /// - `sizeName`: the wire-format size name + /// + /// #### Returns + /// + /// the layout's root node map, or null + @SuppressWarnings("unchecked") + public static Map layoutForSize(Map timelineDoc, + String sizeName) { + if (timelineDoc == null) { + return null; + } + Object layoutsObj = timelineDoc.get("layouts"); + if (!(layoutsObj instanceof Map)) { + return null; + } + Map layouts = (Map) layoutsObj; + Object layout = sizeName == null ? null : layouts.get(sizeName); + if (!(layout instanceof Map)) { + layout = layouts.get("default"); + } + return layout instanceof Map ? (Map) layout : null; + } + + // --- dynamic text ---------------------------------------------------------- + + /// Formats a dynamic-text value the way the OS-native views would show it. Package-private so + /// unit tests can cover the formatting without a `Display`. + /// + /// #### Parameters + /// + /// - `style`: the wire style name (`timerDown`, `timerUp`, `time`, `date`, `relative`) + /// - `dateMillis`: the target epoch millis + /// - `now`: the current epoch millis + static String formatDynamicText(String style, long dateMillis, long now) { + if ("timerUp".equals(style)) { + return formatTimer(now - dateMillis); + } + if ("time".equals(style)) { + Calendar c = Calendar.getInstance(); + c.setTime(new Date(dateMillis)); + int hour = c.get(Calendar.HOUR); + if (hour == 0) { + hour = 12; + } + int minute = c.get(Calendar.MINUTE); + String ampm = c.get(Calendar.AM_PM) == Calendar.AM ? "AM" : "PM"; + return hour + ":" + (minute < 10 ? "0" : "") + minute + " " + ampm; + } + if ("date".equals(style)) { + Calendar c = Calendar.getInstance(); + c.setTime(new Date(dateMillis)); + return MONTHS[c.get(Calendar.MONTH)] + " " + c.get(Calendar.DAY_OF_MONTH); + } + if ("relative".equals(style)) { + return formatRelative(dateMillis, now); + } + // timerDown is the default style + return formatTimer(dateMillis - now); + } + + private static String formatTimer(long millis) { + long total = millis / 1000; + if (total < 0) { + total = 0; + } + long hours = total / 3600; + long minutes = (total % 3600) / 60; + long seconds = total % 60; + StringBuilder sb = new StringBuilder(); + if (hours > 0) { + sb.append(hours).append(':'); + if (minutes < 10) { + sb.append('0'); + } + } + sb.append(minutes).append(':'); + if (seconds < 10) { + sb.append('0'); + } + sb.append(seconds); + return sb.toString(); + } + + private static String formatRelative(long dateMillis, long now) { + long diff = dateMillis - now; + boolean future = diff >= 0; + long minutes = Math.abs(diff) / MINUTE_MILLIS; + String amount; + if (minutes < 60) { + if (minutes < 1) { + return "now"; + } + amount = minutes + " min"; + } else if (minutes < 24 * 60) { + amount = (minutes / 60) + " hr"; + } else { + long days = minutes / (24 * 60); + amount = days + (days == 1 ? " day" : " days"); + } + return future ? "in " + amount : amount + " ago"; + } + + // --- internal layout tree -------------------------------------------------- + + private static final class LNode { + Map map; + String type; + List children; + int prefW; + int prefH; + int x; + int y; + int w; + int h; + } + + @SuppressWarnings("unchecked") + private static LNode build(Map map) { + LNode n = new LNode(); + n.map = map; + Object t = map.get("t"); + n.type = t instanceof String ? (String) t : "box"; + n.children = new ArrayList(); + Object ch = map.get("ch"); + if (ch instanceof List) { + for (Object child : (List) ch) { + if (child instanceof Map) { + n.children.add(build((Map) child)); + } + } + } + return n; + } + + private static void measure(LNode n, Map state, Map images, + long now) { + for (LNode child : n.children) { + measure(child, state, images, now); + } + int cw = 0; + int chh = 0; + if ("col".equals(n.type)) { + int spacing = asInt(n.map.get("spacing"), 0); + for (LNode child : n.children) { + cw = Math.max(cw, child.prefW); + chh += child.prefH; + } + if (n.children.size() > 1) { + chh += spacing * (n.children.size() - 1); + } + } else if ("row".equals(n.type)) { + int spacing = asInt(n.map.get("spacing"), 0); + for (LNode child : n.children) { + chh = Math.max(chh, child.prefH); + cw += child.prefW; + } + if (n.children.size() > 1) { + cw += spacing * (n.children.size() - 1); + } + } else if ("box".equals(n.type)) { + for (LNode child : n.children) { + cw = Math.max(cw, child.prefW); + chh = Math.max(chh, child.prefH); + } + } else if ("text".equals(n.type)) { + Font f = fontOf(n.map); + String s = interpolate(asString(n.map.get("text"), ""), state); + cw = f.stringWidth(s); + chh = f.getHeight(); + } else if ("dyn".equals(n.type)) { + Font f = fontOf(n.map); + String s = dynamicTextOf(n.map, state, now); + cw = f.stringWidth(s); + chh = f.getHeight(); + } else if ("img".equals(n.type)) { + Image img = decodeImage(asString(n.map.get("name"), null), images); + if (img != null) { + cw = img.getWidth(); + chh = img.getHeight(); + } else { + cw = 24; + chh = 24; + } + } else if ("prog".equals(n.type)) { + if ("circular".equals(n.map.get("style"))) { + cw = 28; + chh = 28; + } else { + cw = 120; + chh = 6; + } + } else if ("vec".equals(n.type)) { + // the natural size of a vector node is its view box, in dips + cw = asInt(n.map.get("vw"), 0); + chh = asInt(n.map.get("vh"), 0); + } else if ("spacer".equals(n.type)) { + // the min length applies along the parent's axis; using it on both axes is harmless + // because the cross axis of a spacer never dominates a real sibling + int min = asInt(n.map.get("min"), 0); + cw = min; + chh = min; + } + int[] pad = paddingOf(n.map); + n.prefW = cw + pad[1] + pad[3]; + n.prefH = chh + pad[0] + pad[2]; + int fixedW = asInt(n.map.get("w"), 0); + int fixedH = asInt(n.map.get("h"), 0); + if (fixedW > 0) { + n.prefW = fixedW; + } + if (fixedH > 0) { + n.prefH = fixedH; + } + } + + private static void arrange(LNode n, int x, int y, int w, int h) { + n.x = x; + n.y = y; + n.w = w; + n.h = h; + if (n.children.isEmpty()) { + return; + } + int[] pad = paddingOf(n.map); + int cx = x + pad[3]; + int cy = y + pad[0]; + int cw = Math.max(0, w - pad[1] - pad[3]); + int ch = Math.max(0, h - pad[0] - pad[2]); + if ("col".equals(n.type)) { + arrangeLinear(n, cx, cy, cw, ch, true); + } else if ("row".equals(n.type)) { + arrangeLinear(n, cx, cy, cw, ch, false); + } else { + // box: overlay children aligned by their own alignment, default center + for (LNode child : n.children) { + int bw = Math.min(child.prefW, cw); + int bh = Math.min(child.prefH, ch); + int[] align = alignmentOf(child.map); + int bx = alignPos(align[0], cx, cw, bw); + int by = alignPos(align[1], cy, ch, bh); + arrange(child, bx, by, bw, bh); + } + } + } + + private static void arrangeLinear(LNode n, int cx, int cy, int cw, int ch, boolean vertical) { + int spacing = asInt(n.map.get("spacing"), 0); + int contentMain = vertical ? ch : cw; + int naturalSum = 0; + int totalWeight = 0; + for (LNode child : n.children) { + naturalSum += vertical ? child.prefH : child.prefW; + totalWeight += weightOf(child); + } + if (n.children.size() > 1) { + naturalSum += spacing * (n.children.size() - 1); + } + int leftover = Math.max(0, contentMain - naturalSum); + int cursor = vertical ? cy : cx; + for (LNode child : n.children) { + int weight = weightOf(child); + int extra = 0; + if (weight > 0 && totalWeight > 0) { + extra = leftover * weight / totalWeight; + } + int[] align = alignmentOf(child.map); + if (vertical) { + int mainSize = child.prefH + extra; + int crossSize = Math.min(child.prefW, cw); + int bx = alignPos(align[0], cx, cw, crossSize); + arrange(child, bx, cursor, crossSize, mainSize); + cursor += mainSize + spacing; + } else { + int mainSize = child.prefW + extra; + int crossSize = Math.min(child.prefH, ch); + int by = alignPos(align[1], cy, ch, crossSize); + arrange(child, cursor, by, mainSize, crossSize); + cursor += mainSize + spacing; + } + } + } + + /// Spacers flex by default (implied weight 1) so they absorb leftover space even without an + /// explicit weight. + private static int weightOf(LNode n) { + int weight = asInt(n.map.get("weight"), 0); + if (weight == 0 && "spacer".equals(n.type)) { + return 1; + } + return weight; + } + + private static int alignPos(int align, int start, int available, int size) { + if (align < 0) { + return start; + } + if (align > 0) { + return start + available - size; + } + return start + (available - size) / 2; + } + + // --- drawing ---------------------------------------------------------------- + + @SuppressWarnings("unchecked") + private static void draw(Graphics g, LNode n, Map state, + Map images, boolean dark, long now, List actions, + long[] tick) { + int[] savedClip = g.getClip(); + g.clipRect(n.x, n.y, n.w, n.h); + Object bg = n.map.get("bg"); + if (bg != null) { + int argb = resolveColor(bg, dark, 0); + int corner = asInt(n.map.get("corner"), 0); + setPaint(g, argb); + if (corner > 0) { + g.fillRoundRect(n.x, n.y, n.w, n.h, corner * 2, corner * 2); + } else { + g.fillRect(n.x, n.y, n.w, n.h); + } + g.setAlpha(255); + } + Object action = n.map.get("action"); + if (action instanceof Map) { + Map a = (Map) action; + Object id = a.get("id"); + if (id instanceof String) { + Object p = a.get("p"); + actions.add(new ActionRect(n.x, n.y, n.w, n.h, (String) id, + p instanceof Map ? (Map) p : null)); + } + } + int[] pad = paddingOf(n.map); + int cx = n.x + pad[3]; + int cy = n.y + pad[0]; + int cw = Math.max(0, n.w - pad[1] - pad[3]); + int ch = Math.max(0, n.h - pad[0] - pad[2]); + if ("text".equals(n.type)) { + drawText(g, n.map, interpolate(asString(n.map.get("text"), ""), state), + cx, cy, cw, ch, dark); + } else if ("dyn".equals(n.type)) { + drawText(g, n.map, dynamicTextOf(n.map, state, now), cx, cy, cw, ch, dark); + registerDynTick(n.map, tick, now); + } else if ("img".equals(n.type)) { + drawImageNode(g, n.map, images, cx, cy, cw, ch); + } else if ("prog".equals(n.type)) { + drawProgress(g, n.map, state, cx, cy, cw, ch, dark, now, tick); + } else if ("vec".equals(n.type)) { + drawVector(g, n.map, state, cx, cy, cw, ch, dark); + } else { + for (LNode child : n.children) { + draw(g, child, state, images, dark, now, actions, tick); + } + } + g.setClip(savedClip); + } + + private static void drawText(Graphics g, Map map, String text, int cx, int cy, + int cw, int ch, boolean dark) { + if (text == null || text.length() == 0) { + return; + } + Font f = fontOf(map); + g.setFont(f); + setPaint(g, resolveColor(map.get("color"), dark, + dark ? ROLE_LABEL_DARK : ROLE_LABEL_LIGHT)); + int tw = f.stringWidth(text); + int th = f.getHeight(); + int tx = cx + Math.max(0, (cw - tw) / 2); + int ty = cy + Math.max(0, (ch - th) / 2); + g.drawString(text, tx, ty); + g.setAlpha(255); + } + + private static void drawImageNode(Graphics g, Map map, + Map images, int cx, int cy, int cw, int ch) { + Image img = decodeImage(asString(map.get("name"), null), images); + if (img == null || cw <= 0 || ch <= 0) { + return; + } + // NOTE: the tint attribute is ignored here, see the class comment + String scale = asString(map.get("scale"), "fit"); + int iw = img.getWidth(); + int ih = img.getHeight(); + if ("center".equals(scale)) { + g.drawImage(img, cx + (cw - iw) / 2, cy + (ch - ih) / 2); + return; + } + double ratioFit = Math.min((double) cw / iw, (double) ch / ih); + double ratioFill = Math.max((double) cw / iw, (double) ch / ih); + double ratio = "fill".equals(scale) ? ratioFill : ratioFit; + int dw = Math.max(1, (int) (iw * ratio)); + int dh = Math.max(1, (int) (ih * ratio)); + g.drawImage(img, cx + (cw - dw) / 2, cy + (ch - dh) / 2, dw, dh); + } + + private static void drawProgress(Graphics g, Map map, + Map state, int cx, int cy, int cw, int ch, boolean dark, long now, + long[] tick) { + if (cw <= 0 || ch <= 0) { + return; + } + double fraction = progressFraction(map, state, now, tick); + int color = resolveColor(map.get("color"), dark, ROLE_ACCENT); + if ("circular".equals(map.get("style"))) { + int d = Math.min(cw, ch); + int px = cx + (cw - d) / 2; + int py = cy + (ch - d) / 2; + setPaint(g, color); + g.setAlpha(51); + g.fillArc(px, py, d, d, 0, 360); + g.setAlpha(alphaOf(color)); + // start at 12 o'clock and sweep clockwise + g.fillArc(px, py, d, d, 90, -(int) Math.round(360 * fraction)); + } else { + int barH = Math.min(ch, 6); + int by = cy + (ch - barH) / 2; + setPaint(g, color); + g.setAlpha(51); + g.fillRoundRect(cx, by, cw, barH, barH, barH); + g.setAlpha(alphaOf(color)); + int fillW = (int) Math.round(cw * fraction); + if (fillW > 0) { + g.fillRoundRect(cx, by, fillW, barH, barH, barH); + } + } + g.setAlpha(255); + } + + private static double progressFraction(Map map, Map state, + long now, long[] tick) { + double fraction = 0; + String valueKey = asString(map.get("valueKey"), null); + Object startObj = map.get("start"); + if (valueKey != null) { + Object v = state == null ? null : state.get(valueKey); + if (v instanceof Number) { + fraction = ((Number) v).doubleValue(); + } + } else if (startObj instanceof Number && map.get("end") instanceof Number) { + long start = asLong(startObj, 0); + long end = asLong(map.get("end"), 0); + if (end > start) { + fraction = (now - start) / (double) (end - start); + if (now < end) { + // the interval animates on the OS clock; tick every second while it runs + requestTick(tick, now + 1000); + } + } + } else if (map.get("value") instanceof Number) { + fraction = ((Number) map.get("value")).doubleValue(); + } + if (fraction < 0) { + return 0; + } + if (fraction > 1) { + return 1; + } + return fraction; + } + + private static void registerDynTick(Map map, long[] tick, long now) { + Object style = map.get("style"); + if ("timerDown".equals(style) || "timerUp".equals(style)) { + requestTick(tick, now + 1000); + } else if ("time".equals(style) || "relative".equals(style)) { + requestTick(tick, now - now % MINUTE_MILLIS + MINUTE_MILLIS); + } + } + + private static void requestTick(long[] tick, long when) { + if (tick[0] == 0 || when < tick[0]) { + tick[0] = when; + } + } + + private static String dynamicTextOf(Map map, Map state, + long now) { + long date = 0; + String dateKey = asString(map.get("dateKey"), null); + if (dateKey != null) { + Object v = state == null ? null : state.get(dateKey); + if (v instanceof Number) { + date = ((Number) v).longValue(); + } else { + return ""; + } + } else if (map.get("date") instanceof Number) { + date = asLong(map.get("date"), 0); + } else { + return ""; + } + return formatDynamicText(asString(map.get("style"), "timerDown"), date, now); + } + + // --- vector nodes ----------------------------------------------------------------- + + /// Renders a `vec` node. The view box scales to the node's content bounds preserving aspect + /// ratio (centered). Rotation groups are applied by transforming the geometry mathematically + /// -- every point runs through a software affine matrix -- rather than through + /// `Graphics.rotate`, so the output is identical on every desktop surface regardless of + /// affine support. Curves (ellipses, arcs, round corners) are sampled into short segments and + /// rendered through `GeneralPath` + `fillShape`/`drawShape` when the port supports shapes + /// (JavaSE, Windows and Linux all do). Without shape support the renderer falls back to 1px + /// primitives: geometry still *positions* through the transform, but ellipse/arc orientation + /// stays axis-aligned and stroke widths collapse to one pixel. + /// + /// Text ops draw unrotated at their transformed anchor point (middle of `x`, baseline at + /// `y`); rotating glyphs is not portably possible with core `Graphics`, and clock/gauge + /// labels are typically upright anyway. + /// + /// A vector node never requests a tick by itself: a state-driven rotation (`degKey`) only + /// changes when the timeline entry flips, so clocks publish per-minute entries. + @SuppressWarnings("unchecked") + private static void drawVector(Graphics g, Map map, Map state, + int cx, int cy, int cw, int ch, boolean dark) { + double vw = asDouble(map.get("vw"), 0); + double vh = asDouble(map.get("vh"), 0); + Object opsObj = map.get("ops"); + if (vw <= 0 || vh <= 0 || cw <= 0 || ch <= 0 || !(opsObj instanceof List)) { + return; + } + double scale = Math.min(cw / vw, ch / vh); + if (scale <= 0) { + return; + } + // affine {m00, m01, m10, m11, tx, ty}: px = m00*x + m01*y + tx, py = m10*x + m11*y + ty + double[] t = new double[] { + scale, 0, 0, scale, + cx + (cw - vw * scale) / 2, + cy + (ch - vh * scale) / 2 + }; + drawVectorOps(g, (List) opsObj, t, scale, state, dark); + g.setAlpha(255); + } + + @SuppressWarnings("unchecked") + private static void drawVectorOps(Graphics g, List ops, double[] t, double scale, + Map state, boolean dark) { + boolean shapes = g.isShapeSupported(); + for (Object rawOp : ops) { + if (!(rawOp instanceof Map)) { + continue; + } + Map op = (Map) rawOp; + String o = asString(op.get("o"), ""); + if ("rot".equals(o)) { + double deg = asDouble(op.get("deg"), 0); + String degKey = asString(op.get("degKey"), null); + if (degKey != null) { + Object v = state == null ? null : state.get(degKey); + deg = v instanceof Number ? ((Number) v).doubleValue() : 0; + } + double[] rotated = composeRotation(t, deg, asDouble(op.get("px"), 0), + asDouble(op.get("py"), 0)); + Object nested = op.get("ops"); + if (nested instanceof List) { + drawVectorOps(g, (List) nested, rotated, scale, state, dark); + } + continue; + } + int argb = resolveColor(op.get("c"), dark, dark ? ROLE_LABEL_DARK : ROLE_LABEL_LIGHT); + setPaint(g, argb); + if ("fillRect".equals(o)) { + double x = asDouble(op.get("x"), 0); + double y = asDouble(op.get("y"), 0); + double w = asDouble(op.get("w"), 0); + double h = asDouble(op.get("h"), 0); + fillVectorPoly(g, shapes, t, new double[] {x, y, x + w, y, x + w, y + h, x, y + h}); + } else if ("fillRoundRect".equals(o)) { + fillVectorPoly(g, shapes, t, roundRectOutline(op)); + } else if ("fillEllipse".equals(o)) { + fillVectorPoly(g, shapes, t, ellipseOutline(op, 0, 360, false)); + } else if ("fillArc".equals(o)) { + fillVectorPoly(g, shapes, t, ellipseOutline(op, + asDouble(op.get("start"), 0), asDouble(op.get("sweep"), 0), true)); + } else if ("strokeEllipse".equals(o)) { + strokeVectorPoly(g, shapes, t, ellipseOutline(op, 0, 360, false), true, + asDouble(op.get("sw"), 1) * scale); + } else if ("strokeArc".equals(o)) { + strokeVectorPoly(g, shapes, t, ellipseOutline(op, + asDouble(op.get("start"), 0), asDouble(op.get("sweep"), 0), false), false, + asDouble(op.get("sw"), 1) * scale); + } else if ("line".equals(o)) { + strokeVectorPoly(g, shapes, t, new double[] { + asDouble(op.get("x1"), 0), asDouble(op.get("y1"), 0), + asDouble(op.get("x2"), 0), asDouble(op.get("y2"), 0) + }, false, asDouble(op.get("sw"), 1) * scale); + } else if ("fillPath".equals(o)) { + fillVectorPoly(g, shapes, t, pointsOf(op)); + } else if ("strokePath".equals(o)) { + strokeVectorPoly(g, shapes, t, pointsOf(op), asBoolean(op.get("close")), + asDouble(op.get("sw"), 1) * scale); + } else if ("text".equals(o)) { + drawVectorText(g, op, t, scale, state); + } + } + } + + private static void drawVectorText(Graphics g, Map op, double[] t, + double scale, Map state) { + String text = interpolate(asString(op.get("text"), ""), state); + if (text == null || text.length() == 0) { + return; + } + int px = (int) Math.round(asDouble(op.get("size"), DEFAULT_FONT_SIZE) * scale); + Font f = fontFor(Math.max(1, px), asString(op.get("fw"), "regular")); + g.setFont(f); + double[] anchor = transformPoint(t, asDouble(op.get("x"), 0), asDouble(op.get("y"), 0)); + // anchor middle horizontally, baseline vertically; drawString draws from the top + int tx = (int) Math.round(anchor[0] - f.stringWidth(text) / 2.0); + int ty = (int) Math.round(anchor[1] - f.getAscent()); + g.drawString(text, tx, ty); + } + + private static void fillVectorPoly(Graphics g, boolean shapes, double[] t, double[] pts) { + if (pts.length < 6) { + return; + } + if (shapes) { + g.fillShape(transformedPath(t, pts, true)); + return; + } + int n = pts.length / 2; + int[] xs = new int[n]; + int[] ys = new int[n]; + transformToArrays(t, pts, xs, ys); + g.fillPolygon(xs, ys, n); + } + + private static void strokeVectorPoly(Graphics g, boolean shapes, double[] t, double[] pts, + boolean close, double strokeWidthPx) { + if (pts.length < 4) { + return; + } + if (shapes) { + Stroke stroke = new Stroke((float) Math.max(1, strokeWidthPx), Stroke.CAP_ROUND, + Stroke.JOIN_ROUND, 1f); + g.drawShape(transformedPath(t, pts, close), stroke); + return; + } + // primitive fallback: 1px segments + int n = pts.length / 2; + int[] xs = new int[n]; + int[] ys = new int[n]; + transformToArrays(t, pts, xs, ys); + for (int i = 1; i < n; i++) { + g.drawLine(xs[i - 1], ys[i - 1], xs[i], ys[i]); + } + if (close && n > 2) { + g.drawLine(xs[n - 1], ys[n - 1], xs[0], ys[0]); + } + } + + private static GeneralPath transformedPath(double[] t, double[] pts, boolean close) { + GeneralPath path = new GeneralPath(); + double[] p = transformPoint(t, pts[0], pts[1]); + path.moveTo((float) p[0], (float) p[1]); + for (int i = 2; i + 1 < pts.length; i += 2) { + p = transformPoint(t, pts[i], pts[i + 1]); + path.lineTo((float) p[0], (float) p[1]); + } + if (close) { + path.closePath(); + } + return path; + } + + private static void transformToArrays(double[] t, double[] pts, int[] xs, int[] ys) { + for (int i = 0; i < xs.length; i++) { + double[] p = transformPoint(t, pts[i * 2], pts[i * 2 + 1]); + xs[i] = (int) Math.round(p[0]); + ys[i] = (int) Math.round(p[1]); + } + } + + /// Samples the outline of an ellipse/arc op into a poly-line, in view-box coordinates. For + /// a pie the center is prepended so closing the path produces the slice. + private static double[] ellipseOutline(Map op, double startDeg, + double sweepDeg, boolean pie) { + double cx = asDouble(op.get("cx"), 0); + double cy = asDouble(op.get("cy"), 0); + double rx = asDouble(op.get("rx"), 0); + double ry = asDouble(op.get("ry"), 0); + double sweep = sweepDeg; + if (Math.abs(sweep) >= 360) { + sweep = sweep < 0 ? -360 : 360; + } + // one segment every ~4 degrees keeps small widget curves smooth without excess points + int segs = Math.max(8, Math.min(90, (int) Math.ceil(Math.abs(sweep) / 4))); + int extra = pie ? 1 : 0; + double[] pts = new double[(segs + 1 + extra) * 2]; + if (pie) { + pts[0] = cx; + pts[1] = cy; + } + for (int i = 0; i <= segs; i++) { + double rad = clockRadians(startDeg + sweep * i / segs); + pts[(i + extra) * 2] = cx + rx * Math.cos(rad); + pts[(i + extra) * 2 + 1] = cy + ry * Math.sin(rad); + } + return pts; + } + + /// Samples the outline of a rounded rectangle op, corners as quarter arcs. + private static double[] roundRectOutline(Map op) { + double x = asDouble(op.get("x"), 0); + double y = asDouble(op.get("y"), 0); + double w = asDouble(op.get("w"), 0); + double h = asDouble(op.get("h"), 0); + double r = Math.max(0, Math.min(asDouble(op.get("corner"), 0), Math.min(w, h) / 2)); + if (r <= 0) { + return new double[] {x, y, x + w, y, x + w, y + h, x, y + h}; + } + int segs = 6; + // corner centers in paint order starting after the top-left corner, with the clock + // start angle of each quarter arc + double[] centers = { + x + w - r, y + r, 0, + x + w - r, y + h - r, 90, + x + r, y + h - r, 180, + x + r, y + r, 270 + }; + double[] pts = new double[4 * (segs + 1) * 2]; + int idx = 0; + for (int corner = 0; corner < 4; corner++) { + double ccx = centers[corner * 3]; + double ccy = centers[corner * 3 + 1]; + double start = centers[corner * 3 + 2]; + for (int i = 0; i <= segs; i++) { + double rad = clockRadians(start + 90.0 * i / segs); + pts[idx++] = ccx + r * Math.cos(rad); + pts[idx++] = ccy + r * Math.sin(rad); + } + } + return pts; + } + + private static double[] pointsOf(Map op) { + Object ptsObj = op.get("pts"); + if (!(ptsObj instanceof List)) { + return new double[0]; + } + List list = (List) ptsObj; + int n = list.size() - list.size() % 2; + double[] pts = new double[n]; + for (int i = 0; i < n; i++) { + pts[i] = asDouble(list.get(i), 0); + } + return pts; + } + + /// Converts a clock-convention angle (degrees, 0 = 12 o'clock, clockwise positive) to screen + /// radians measured from 3 o'clock advancing clockwise (y grows downward), the convention of + /// the sampling math: a point at clock angle `d` sits at + /// `(cx + r*cos(clockRadians(d)), cy + r*sin(clockRadians(d)))`. Package-private for unit + /// tests. + static double clockRadians(double clockDegrees) { + return Math.toRadians(clockDegrees - 90); + } + + /// Converts a clock-convention start angle to the `Graphics.fillArc`/`drawArc` convention + /// (degrees, 0 = 3 o'clock, counterclockwise positive). Used by the primitive fallback and + /// kept as a documented, testable helper of the platform conversion. Package-private for + /// unit tests. + static int cn1ArcStartDegrees(double clockStartDeg) { + return (int) Math.round(90 - clockStartDeg); + } + + /// Converts a clockwise-positive sweep to the counterclockwise-positive + /// `Graphics.fillArc`/`drawArc` sweep. Package-private for unit tests. + static int cn1ArcSweepDegrees(double clockSweepDeg) { + return (int) Math.round(-clockSweepDeg); + } + + /// Composes a clockwise-on-screen rotation about a pivot (both in the source coordinate + /// space) onto an affine `{m00, m01, m10, m11, tx, ty}`. Package-private for unit tests. + static double[] composeRotation(double[] t, double clockwiseDeg, double px, double py) { + double a = Math.toRadians(clockwiseDeg); + double cos = Math.cos(a); + double sin = Math.sin(a); + // local matrix: translate(px, py) * rotate(a) * translate(-px, -py); with y growing + // downward the standard rotation matrix advances clockwise on screen + double m00 = cos; + double m01 = -sin; + double m10 = sin; + double m11 = cos; + double mtx = px - cos * px + sin * py; + double mty = py - sin * px - cos * py; + return new double[] { + t[0] * m00 + t[1] * m10, + t[0] * m01 + t[1] * m11, + t[2] * m00 + t[3] * m10, + t[2] * m01 + t[3] * m11, + t[0] * mtx + t[1] * mty + t[4], + t[2] * mtx + t[3] * mty + t[5] + }; + } + + /// Applies an affine `{m00, m01, m10, m11, tx, ty}` to a point. Package-private for unit + /// tests. + static double[] transformPoint(double[] t, double x, double y) { + return new double[] { + t[0] * x + t[1] * y + t[4], + t[2] * x + t[3] * y + t[5] + }; + } + + // --- attribute helpers -------------------------------------------------------- + + /// Replaces `${key}` placeholders with `String.valueOf` of the state value; missing keys + /// render as an empty string. Package-private for unit tests. + static String interpolate(String text, Map state) { + if (text == null || text.indexOf("${") < 0) { + return text; + } + StringBuilder sb = new StringBuilder(text.length()); + int i = 0; + int len = text.length(); + while (i < len) { + int start = text.indexOf("${", i); + if (start < 0) { + sb.append(text.substring(i)); + break; + } + int end = text.indexOf('}', start + 2); + if (end < 0) { + sb.append(text.substring(i)); + break; + } + sb.append(text.substring(i, start)); + String key = text.substring(start + 2, end); + Object value = state == null ? null : state.get(key); + if (value != null) { + sb.append(String.valueOf(value)); + } + i = end + 1; + } + return sb.toString(); + } + + /// Resolves a wire color (`{"l":..,"d":..}` pair or `{"role":..}`) to ARGB. Package-private + /// for unit tests. + @SuppressWarnings("unchecked") + static int resolveColor(Object colorObj, boolean dark, int fallback) { + if (!(colorObj instanceof Map)) { + return fallback; + } + Map m = (Map) colorObj; + Object role = m.get("role"); + if (role instanceof String) { + if ("label".equals(role)) { + return dark ? ROLE_LABEL_DARK : ROLE_LABEL_LIGHT; + } + if ("secondaryLabel".equals(role)) { + return dark ? ROLE_SECONDARY_DARK : ROLE_SECONDARY_LIGHT; + } + if ("background".equals(role)) { + return dark ? ROLE_BACKGROUND_DARK : ROLE_BACKGROUND_LIGHT; + } + if ("accent".equals(role)) { + return ROLE_ACCENT; + } + return fallback; + } + Object v = dark ? m.get("d") : m.get("l"); + if (v instanceof Number) { + return (int) ((Number) v).longValue(); + } + return fallback; + } + + /// Applies an ARGB color to the graphics: RGB via `setColor`, alpha via `setAlpha`. The + /// alpha byte is honored verbatim (a fully transparent color draws nothing), matching how + /// the iOS and Android renderers consume the same wire value. + private static void setPaint(Graphics g, int argb) { + g.setColor(argb & 0xffffff); + g.setAlpha(alphaOf(argb)); + } + + private static int alphaOf(int argb) { + return (argb >>> 24) & 0xff; + } + + private static Font fontOf(Map map) { + int size = asInt(map.get("size"), 0); + return fontFor(size <= 0 ? DEFAULT_FONT_SIZE : size, + asString(map.get("fw"), "regular")); + } + + private static Font fontFor(int px, String weight) { + boolean bold = "semibold".equals(weight) || "bold".equals(weight); + try { + // exact pixel sizing with weights when the port ships native: fonts + String name; + if (bold) { + name = "native:MainBold"; + } else if ("light".equals(weight)) { + name = "native:MainLight"; + } else { + name = "native:MainRegular"; + } + Font f = Font.createTrueTypeFont(name, name); + if (f != null) { + return f.derive(px, Font.STYLE_PLAIN); + } + } catch (Throwable ignored) { + // fall through to the coarse system font buckets + } + int bucket; + if (px <= 12) { + bucket = Font.SIZE_SMALL; + } else if (px <= 18) { + bucket = Font.SIZE_MEDIUM; + } else { + bucket = Font.SIZE_LARGE; + } + return Font.createSystemFont(Font.FACE_SYSTEM, + bold ? Font.STYLE_BOLD : Font.STYLE_PLAIN, bucket); + } + + private static int[] paddingOf(Map map) { + int[] pad = new int[4]; + Object p = map.get("pad"); + if (p instanceof List) { + List list = (List) p; + for (int i = 0; i < 4 && i < list.size(); i++) { + pad[i] = asInt(list.get(i), 0); + } + } + return pad; + } + + /// Returns `{horizontal, vertical}` alignment components, each -1 (leading/top), 0 (center) + /// or 1 (trailing/bottom). Defaults to center. + private static int[] alignmentOf(Map map) { + String a = asString(map.get("align"), "center"); + int hAlign = 0; + int vAlign = 0; + if ("leading".equals(a) || "topLeading".equals(a) || "bottomLeading".equals(a)) { + hAlign = -1; + } else if ("trailing".equals(a) || "topTrailing".equals(a) || "bottomTrailing".equals(a)) { + hAlign = 1; + } + if ("top".equals(a) || "topLeading".equals(a) || "topTrailing".equals(a)) { + vAlign = -1; + } else if ("bottom".equals(a) || "bottomLeading".equals(a) || "bottomTrailing".equals(a)) { + vAlign = 1; + } + return new int[] {hAlign, vAlign}; + } + + private static Image decodeImage(String name, Map images) { + if (name == null || name.length() == 0 || images == null) { + return null; + } + Image cached = decodedImages.get(name); + if (cached != null) { + return cached; + } + byte[] data = images.get(name); + if (data == null) { + return null; + } + try { + Image img = Image.createImage(new ByteArrayInputStream(data)); + if (decodedImages.size() >= DECODED_IMAGE_CACHE_CAP) { + decodedImages.clear(); + } + decodedImages.put(name, img); + return img; + } catch (Throwable t) { + Log.e(t); + return null; + } + } + + @SuppressWarnings("unchecked") + private static List> entriesOf(Map timelineDoc) { + List> result = new ArrayList>(); + if (timelineDoc == null) { + return result; + } + Object entries = timelineDoc.get("entries"); + if (entries instanceof List) { + for (Object e : (List) entries) { + if (e instanceof Map) { + result.add((Map) e); + } + } + } + return result; + } + + private static int asInt(Object o, int def) { + return o instanceof Number ? ((Number) o).intValue() : def; + } + + private static long asLong(Object o, long def) { + return o instanceof Number ? ((Number) o).longValue() : def; + } + + private static double asDouble(Object o, double def) { + return o instanceof Number ? ((Number) o).doubleValue() : def; + } + + /// JSONParser hands booleans back as the strings "true"/"false" unless configured + /// otherwise; accept both representations. + private static boolean asBoolean(Object o) { + return Boolean.TRUE.equals(o) || "true".equals(o); + } + + private static String asString(Object o, String def) { + return o instanceof String ? (String) o : def; + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceRow.java b/CodenameOne/src/com/codename1/surfaces/SurfaceRow.java new file mode 100644 index 00000000000..09293cc0c89 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceRow.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import java.util.Map; + +/// A container that lays its children out horizontally. Maps to a SwiftUI `HStack`, an Android +/// horizontal `LinearLayout` and a horizontal box on desktop surfaces. +public class SurfaceRow extends SurfaceContainer { + private int spacing; + + /// Sets the horizontal gap between consecutive children. + /// + /// #### Parameters + /// + /// - `spacing`: the gap in dips + /// + /// #### Returns + /// + /// this row, for chaining + public SurfaceRow setSpacing(int spacing) { + this.spacing = spacing; + return this; + } + + /// Returns the gap between consecutive children in dips. + public int getSpacing() { + return spacing; + } + + @Override + public SurfaceRow add(SurfaceNode child) { + super.add(child); + return this; + } + + @Override + public SurfaceRow setPadding(int all) { + super.setPadding(all); + return this; + } + + @Override + public SurfaceRow setPadding(int top, int right, int bottom, int left) { + super.setPadding(top, right, bottom, left); + return this; + } + + @Override + public SurfaceRow setBackground(SurfaceColor color) { + super.setBackground(color); + return this; + } + + @Override + public SurfaceRow setCornerRadius(int radius) { + super.setCornerRadius(radius); + return this; + } + + @Override + public SurfaceRow setAlignment(SurfaceAlignment alignment) { + super.setAlignment(alignment); + return this; + } + + @Override + public SurfaceRow setWeight(int weight) { + super.setWeight(weight); + return this; + } + + @Override + public SurfaceRow setSize(int widthDips, int heightDips) { + super.setSize(widthDips, heightDips); + return this; + } + + @Override + public SurfaceRow setAction(String actionId) { + super.setAction(actionId); + return this; + } + + @Override + public SurfaceRow setAction(String actionId, Map params) { + super.setAction(actionId, params); + return this; + } + + @Override + String getType() { + return "row"; + } + + @Override + void serializeContent(Map out, Map images, int depth) { + if (spacing != 0) { + out.put("spacing", Integer.valueOf(spacing)); + } + super.serializeContent(out, images, depth); + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceSerializer.java b/CodenameOne/src/com/codename1/surfaces/SurfaceSerializer.java new file mode 100644 index 00000000000..54b7044c27a --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceSerializer.java @@ -0,0 +1,346 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import com.codename1.io.JSONWriter; +import com.codename1.io.Log; +import com.codename1.ui.EncodedImage; +import com.codename1.ui.Image; +import com.codename1.ui.util.ImageIO; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/// Serializes surface descriptors to the canonical wire format shared by every port: a compact +/// JSON document plus PNG blobs named by content hash. This class is an internal seam between the +/// core API and the platform bridges -- it is public only because ports live in separate +/// artifacts; apps never call it. +/// +/// The wire format is versioned (`"v": 1`) and deterministic: node maps preserve build order, +/// state maps are emitted with sorted keys, timeline entries are sorted ascending by date and +/// image names derive from a hash of the PNG bytes (identical art always gets the same name). +public final class SurfaceSerializer { + private static final int PAYLOAD_WARN_BYTES = 200 * 1024; + + private SurfaceSerializer() { + } + + /// Serializes a widget timeline. + /// + /// #### Parameters + /// + /// - `kindId`: the widget kind id the timeline belongs to + /// - `timeline`: the timeline to serialize + /// - `imagesOut`: receives PNG blobs keyed by registered name + /// + /// #### Returns + /// + /// 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) { + throw new IllegalArgumentException("A widget timeline needs content: call " + + "setContent(...) before publishing"); + } + Map doc = new LinkedHashMap(); + doc.put("v", Integer.valueOf(1)); + doc.put("kind", kindId); + doc.put("reload", timeline.getReloadPolicy() == WidgetTimeline.RELOAD_NEVER ? "never" : "atEnd"); + + Map layouts = new LinkedHashMap(); + SurfaceNode def = timeline.getDefaultContent(); + if (def != null) { + layouts.put("default", def.toMap(imagesOut, 0)); + } + for (WidgetSize size : WidgetSize.values()) { + SurfaceNode content = timeline.getExplicitContent(size); + if (content != null) { + layouts.put(size.getJsonName(), content.toMap(imagesOut, 0)); + } + } + doc.put("layouts", layouts); + + List entries = + new ArrayList(timeline.getEntries()); + if (entries.isEmpty()) { + entries.add(new WidgetTimeline.Entry(new Date(), + new LinkedHashMap())); + } + sortEntries(entries); + List entryList = new ArrayList(entries.size()); + for (WidgetTimeline.Entry e : entries) { + Map em = new LinkedHashMap(); + em.put("date", Long.valueOf(e.getDate().getTime())); + em.put("state", sortedCopy(e.getState())); + entryList.add(em); + } + doc.put("entries", entryList); + doc.put("images", referencedImageNames(layouts, imagesOut)); + return emit(doc, imagesOut); + } + + /// Serializes a live activity descriptor together with its initial state. + /// + /// #### Parameters + /// + /// - `descriptor`: the descriptor to serialize + /// - `state`: the initial state map, may be null + /// - `imagesOut`: receives PNG blobs keyed by registered name + /// + /// #### Returns + /// + /// the descriptor JSON + public static String serializeLiveActivity(LiveActivityDescriptor descriptor, + Map state, Map imagesOut) { + if (descriptor.getContent() == null) { + throw new IllegalArgumentException("A live activity needs content: call " + + "setContent(...) before starting it"); + } + Map doc = new LinkedHashMap(); + doc.put("v", Integer.valueOf(1)); + doc.put("type", descriptor.getActivityType()); + if (descriptor.getTint() != null) { + doc.put("tint", colorMap(descriptor.getTint())); + } + if (descriptor.getAndroidChannelId() != null) { + Map android = new LinkedHashMap(); + android.put("channel", descriptor.getAndroidChannelId()); + doc.put("android", android); + } + doc.put("content", descriptor.getContent().toMap(imagesOut, 0)); + Map island = new LinkedHashMap(); + putRegion(island, "compactLeading", descriptor.getCompactLeading(), imagesOut); + putRegion(island, "compactTrailing", descriptor.getCompactTrailing(), imagesOut); + putRegion(island, "minimal", descriptor.getMinimal(), imagesOut); + putRegion(island, "expandedLeading", descriptor.getExpandedLeading(), imagesOut); + putRegion(island, "expandedTrailing", descriptor.getExpandedTrailing(), imagesOut); + putRegion(island, "expandedCenter", descriptor.getExpandedCenter(), imagesOut); + putRegion(island, "expandedBottom", descriptor.getExpandedBottom(), imagesOut); + if (!island.isEmpty()) { + doc.put("island", island); + } + doc.put("state", sortedCopy(state == null ? new LinkedHashMap() : state)); + Map rendered = new LinkedHashMap(); + rendered.put("content", doc.get("content")); + if (!island.isEmpty()) { + rendered.put("island", island); + } + doc.put("images", referencedImageNames(rendered, imagesOut)); + return emit(doc, imagesOut); + } + + /// Serializes a state map with sorted keys. + /// + /// #### Parameters + /// + /// - `state`: the state map, may be null + /// + /// #### Returns + /// + /// the state JSON + public static String serializeState(Map state) { + return JSONWriter.toJson(sortedCopy(state == null + ? new LinkedHashMap() : state)); + } + + /// Serializes a widget kind declaration. + /// + /// #### Parameters + /// + /// - `kind`: the kind to serialize + /// + /// #### Returns + /// + /// the kind JSON + public static String serializeKind(WidgetKind kind) { + Map doc = new LinkedHashMap(); + doc.put("id", kind.getId()); + if (kind.getDisplayName() != null) { + doc.put("name", kind.getDisplayName()); + } + if (kind.getDescription() != null) { + doc.put("description", kind.getDescription()); + } + List sizes = new ArrayList(); + for (WidgetSize s : kind.getSupportedSizes()) { + sizes.add(s.getJsonName()); + } + doc.put("sizes", sizes); + return JSONWriter.toJson(doc); + } + + // --- package-private helpers used by the node model ---------------------- + + /// Serializes a color to its wire form. + static Map colorMap(SurfaceColor color) { + Map m = new LinkedHashMap(); + if (color.getRole() != null) { + m.put("role", color.getRole()); + } else { + m.put("l", Integer.valueOf(color.getLight())); + m.put("d", Integer.valueOf(color.getDark())); + } + return m; + } + + /// Copies a state or parameter map into a key-sorted map so serialization is deterministic + /// regardless of the map implementation the app supplied. + static Map sortedCopy(Map map) { + Map sorted = new TreeMap(); + if (map != null) { + sorted.putAll(map); + } + return sorted; + } + + /// Encodes an image to PNG, names the blob by content hash and registers it in `images`. + /// Returns the registered name, or null when encoding failed. + static String registerImage(Image image, Map images) { + return registerImageBytes(encode(image), images); + } + + /// Names a PNG blob by content hash and registers it in `images`. Returns the registered + /// name, or null for a null blob. + static String registerImageBytes(byte[] data, Map images) { + if (data == null) { + return null; + } + String name = "img" + fnv1a(data); + if (!images.containsKey(name)) { + images.put(name, data); + } + return name; + } + + // --- internals ------------------------------------------------------------ + + private static void putRegion(Map island, String key, SurfaceNode node, + Map images) { + if (node != null) { + island.put(key, node.toMap(images, 0)); + } + } + + /// The document's `images` list names EVERY image the descriptor references -- both blobs + /// registered by this publish and `SurfaceImage(registeredName)` references to blobs shipped + /// earlier. Bridges use it to garbage collect persisted blobs the replacement timeline no + /// longer references, so it must be the complete reference set, sorted for determinism. + private static List referencedImageNames(Object serializedTree, + Map imagesOut) { + java.util.TreeSet names = new java.util.TreeSet(imagesOut.keySet()); + collectImageNames(serializedTree, names); + return new ArrayList(names); + } + + private static void collectImageNames(Object node, java.util.Set out) { + if (node instanceof Map) { + Map m = (Map) node; + if ("img".equals(m.get("t"))) { + Object name = m.get("name"); + if (name instanceof String && ((String) name).length() > 0) { + out.add((String) name); + } + } + for (Object value : m.values()) { + collectImageNames(value, out); + } + } else if (node instanceof List) { + for (Object value : (List) node) { + collectImageNames(value, out); + } + } + } + + private static String emit(Map doc, Map images) { + String json = JSONWriter.toJson(doc); + int total = json.length(); + for (byte[] blob : images.values()) { + total += blob.length; + } + if (total > PAYLOAD_WARN_BYTES) { + Log.p("Surfaces: the published payload is " + (total / 1024) + "kb; widget renderers " + + "run under tight memory and transaction budgets, consider smaller images"); + } + return json; + } + + private static byte[] encode(Image img) { + if (img == null) { + return null; + } + try { + if (img instanceof EncodedImage) { + return ((EncodedImage) img).getImageData(); + } + ImageIO io = ImageIO.getImageIO(); + if (io != null) { + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + io.save(img, bo, ImageIO.FORMAT_PNG, 1f); + return bo.toByteArray(); + } + } catch (Throwable t) { + Log.e(t); + } + return null; + } + + private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray(); + + private static String fnv1a(byte[] data) { + long hash = 0xcbf29ce484222325L; + for (byte b : data) { + hash ^= b & 0xff; + hash *= 0x100000001b3L; + } + // formatted by hand: the CLDC bootclasspath the Ant core build compiles + // against has no Long.toHexString + char[] hex = new char[16]; + for (int i = 15; i >= 0; i--) { + hex[i] = HEX_DIGITS[(int) (hash & 0xf)]; + hash >>>= 4; + } + return new String(hex); + } + + private static void sortEntries(List entries) { + // insertion sort keeps this dependency-free and stable; timelines are short + for (int i = 1; i < entries.size(); i++) { + WidgetTimeline.Entry e = entries.get(i); + int j = i - 1; + while (j >= 0 && entries.get(j).getDate().getTime() > e.getDate().getTime()) { + entries.set(j + 1, entries.get(j)); + j--; + } + entries.set(j + 1, e); + } + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceSpacer.java b/CodenameOne/src/com/codename1/surfaces/SurfaceSpacer.java new file mode 100644 index 00000000000..a8537901230 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceSpacer.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import java.util.Map; + +/// A flexible spacer that absorbs the leftover space of its parent row or column, pushing its +/// siblings apart. Maps to a SwiftUI `Spacer` and a weighted empty view on Android. +public class SurfaceSpacer extends SurfaceNode { + private int minDips; + + /// Creates a spacer. + public SurfaceSpacer() { + } + + /// Creates a spacer with a minimum length along the parent's axis. + /// + /// #### Parameters + /// + /// - `minDips`: the minimum length in dips + public SurfaceSpacer(int minDips) { + this.minDips = minDips; + } + + /// Returns the minimum length along the parent's axis in dips. + public int getMinDips() { + return minDips; + } + + @Override + String getType() { + return "spacer"; + } + + @Override + void serializeContent(Map out, Map images, int depth) { + if (minDips != 0) { + out.put("min", Integer.valueOf(minDips)); + } + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceText.java b/CodenameOne/src/com/codename1/surfaces/SurfaceText.java new file mode 100644 index 00000000000..3c72e1a8d11 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceText.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import java.util.Map; + +/// A static text node. The text may embed `${key}` placeholders that the platform renderer +/// substitutes from the state map of the current timeline entry or live-activity update, so +/// frequently changing values do not require re-publishing the whole descriptor: +/// +/// ```java +/// new SurfaceText("${status}").setFontSize(13).setColor(SurfaceColor.SECONDARY_LABEL) +/// ``` +/// +/// For values the OS should animate on its own clock (countdowns, elapsed time) use +/// `SurfaceDynamicText` instead. +public class SurfaceText extends SurfaceNode { + private String text; + private int fontSize; + private SurfaceFontWeight fontWeight; + private SurfaceColor color; + private int maxLines; + + /// Creates a text node. + /// + /// #### Parameters + /// + /// - `text`: literal text, optionally embedding `${key}` state placeholders + public SurfaceText(String text) { + this.text = text; + } + + /// Sets the font size. + /// + /// #### Parameters + /// + /// - `fontSize`: the size in dips + /// + /// #### Returns + /// + /// this text node, for chaining + public SurfaceText setFontSize(int fontSize) { + this.fontSize = fontSize; + return this; + } + + /// Sets the font weight. + /// + /// #### Parameters + /// + /// - `fontWeight`: the weight + /// + /// #### Returns + /// + /// this text node, for chaining + public SurfaceText setFontWeight(SurfaceFontWeight fontWeight) { + this.fontWeight = fontWeight; + return this; + } + + /// Sets the text color. + /// + /// #### Parameters + /// + /// - `color`: the color + /// + /// #### Returns + /// + /// this text node, for chaining + public SurfaceText setColor(SurfaceColor color) { + this.color = color; + return this; + } + + /// Limits the number of rendered lines. + /// + /// #### Parameters + /// + /// - `maxLines`: maximum line count, 0 for unlimited + /// + /// #### Returns + /// + /// this text node, for chaining + public SurfaceText setMaxLines(int maxLines) { + this.maxLines = maxLines; + return this; + } + + /// Returns the text, potentially containing `${key}` placeholders. + public String getText() { + return text; + } + + /// Returns the font size in dips, 0 for the platform default. + public int getFontSize() { + return fontSize; + } + + /// Returns the font weight, or null for the platform default. + public SurfaceFontWeight getFontWeight() { + return fontWeight; + } + + /// Returns the text color, or null for the platform default. + public SurfaceColor getColor() { + return color; + } + + /// Returns the maximum line count, 0 for unlimited. + public int getMaxLines() { + return maxLines; + } + + @Override + public SurfaceText setPadding(int all) { + super.setPadding(all); + return this; + } + + @Override + public SurfaceText setPadding(int top, int right, int bottom, int left) { + super.setPadding(top, right, bottom, left); + return this; + } + + @Override + public SurfaceText setBackground(SurfaceColor background) { + super.setBackground(background); + return this; + } + + @Override + public SurfaceText setCornerRadius(int radius) { + super.setCornerRadius(radius); + return this; + } + + @Override + public SurfaceText setAlignment(SurfaceAlignment alignment) { + super.setAlignment(alignment); + return this; + } + + @Override + public SurfaceText setWeight(int weight) { + super.setWeight(weight); + return this; + } + + @Override + public SurfaceText setSize(int widthDips, int heightDips) { + super.setSize(widthDips, heightDips); + return this; + } + + @Override + public SurfaceText setAction(String actionId) { + super.setAction(actionId); + return this; + } + + @Override + public SurfaceText setAction(String actionId, Map params) { + super.setAction(actionId, params); + return this; + } + + @Override + String getType() { + return "text"; + } + + @Override + void serializeContent(Map out, Map images, int depth) { + out.put("text", text == null ? "" : text); + if (fontSize != 0) { + out.put("size", Integer.valueOf(fontSize)); + } + if (fontWeight != null) { + out.put("fw", fontWeight.getJsonName()); + } + if (color != null) { + out.put("color", SurfaceSerializer.colorMap(color)); + } + if (maxLines != 0) { + out.put("maxLines", Integer.valueOf(maxLines)); + } + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/SurfaceVector.java b/CodenameOne/src/com/codename1/surfaces/SurfaceVector.java new file mode 100644 index 00000000000..9af1f1dfc11 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/SurfaceVector.java @@ -0,0 +1,576 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// A retained vector drawing node: a small catalog of fill/stroke/text operations recorded in +/// paint order and replayed natively by every platform renderer (SwiftUI `Canvas` on iOS, an +/// in-process bitmap on Android, Codename One `Graphics` on desktop). It exists for the widgets +/// the sealed template catalog cannot express -- clocks, gauges, dials and similar custom art -- +/// without shipping pre-rendered images for every state. +/// +/// Operations use a logical coordinate space (the *view box* passed to the constructor) that is +/// scaled to the node's laid-out bounds preserving aspect ratio and centered, so the same op list +/// renders correctly at every widget size. +/// +/// #### Angles: the clock convention +/// +/// All angles in this class are degrees where **0 points up (12 o'clock) and positive angles +/// advance clockwise** -- the natural convention for clock hands and gauge needles. Renderers +/// convert internally to each platform's native arc convention. +/// +/// #### Rotation groups +/// +/// Operations added between `beginRotation(...)` and `endRotation()` rotate together around a +/// pivot. The angle is either fixed or read from the entry state map by key, which is what makes +/// an analog clock cheap: publish the face layout once and drive the hands with per-entry state: +/// +/// ```java +/// SurfaceVector face = new SurfaceVector(200, 200) +/// .fillEllipse(100, 100, 96, 96, SurfaceColor.BACKGROUND) +/// .strokeEllipse(100, 100, 96, 96, 4, SurfaceColor.LABEL) +/// .beginRotation("hourAngle", 100, 100) +/// .line(100, 100, 100, 52, 8, SurfaceColor.LABEL) +/// .endRotation() +/// .beginRotation("minuteAngle", 100, 100) +/// .line(100, 100, 100, 24, 5, SurfaceColor.ACCENT) +/// .endRotation() +/// .fillEllipse(100, 100, 6, 6, SurfaceColor.ACCENT); +/// // one timeline entry per minute; the OS flips entries on schedule without waking the app +/// WidgetTimeline t = new WidgetTimeline().setContent(face); +/// for (int m = 0; m < 60; m++) { +/// int totalMinutes = hourOfDay * 60 + minute + m; +/// Map state = new HashMap(); +/// state.put("minuteAngle", Float.valueOf(totalMinutes % 60 * 6f)); +/// state.put("hourAngle", Float.valueOf(totalMinutes % 720 * 0.5f)); +/// t.addEntry(new Date(startOfMinute + m * 60000L), state); +/// } +/// ``` +/// +/// A state-driven angle does **not** tick by itself -- state is per timeline entry, so a clock +/// publishes one entry per minute (as above) and the OS flips them on its own schedule. +/// +/// Descriptors cap the total operation count at 512 per vector node; exceeding it fails at +/// serialization time. Unbalanced `beginRotation`/`endRotation` pairs also fail at serialization +/// time with `IllegalStateException`. +public class SurfaceVector extends SurfaceNode { + /// The maximum number of drawing operations (rotation groups included) in one vector node. + static final int MAX_OPS = 512; + + private final int viewBoxWidth; + private final int viewBoxHeight; + private final List> ops = new ArrayList>(); + /// Stack of op-list targets; index 0 is the root list, deeper entries are open rotation + /// groups. Ops append to the deepest entry. + private final List>> targets = + new ArrayList>>(); + private int opCount; + + /// Creates a vector node with a logical coordinate space. The view box is scaled to the + /// node's laid-out bounds preserving aspect ratio (centered); it also provides the node's + /// natural size in dips when no fixed size or weight applies. + /// + /// #### Parameters + /// + /// - `viewBoxWidth`: the logical width of the drawing coordinate space + /// - `viewBoxHeight`: the logical height of the drawing coordinate space + public SurfaceVector(int viewBoxWidth, int viewBoxHeight) { + if (viewBoxWidth <= 0 || viewBoxHeight <= 0) { + throw new IllegalArgumentException("The view box dimensions must be positive"); + } + this.viewBoxWidth = viewBoxWidth; + this.viewBoxHeight = viewBoxHeight; + targets.add(ops); + } + + /// Fills an axis-aligned rectangle. + /// + /// #### Parameters + /// + /// - `x`: left edge in view-box units + /// - `y`: top edge in view-box units + /// - `w`: width in view-box units + /// - `h`: height in view-box units + /// - `c`: the fill color + /// + /// #### Returns + /// + /// this vector node, for chaining + public SurfaceVector fillRect(float x, float y, float w, float h, SurfaceColor c) { + Map op = op("fillRect", c); + op.put("x", Float.valueOf(x)); + op.put("y", Float.valueOf(y)); + op.put("w", Float.valueOf(w)); + op.put("h", Float.valueOf(h)); + return add(op); + } + + /// Fills a rectangle with rounded corners. + /// + /// #### Parameters + /// + /// - `x`: left edge in view-box units + /// - `y`: top edge in view-box units + /// - `w`: width in view-box units + /// - `h`: height in view-box units + /// - `corner`: the corner radius in view-box units + /// - `c`: the fill color + /// + /// #### Returns + /// + /// this vector node, for chaining + public SurfaceVector fillRoundRect(float x, float y, float w, float h, float corner, + SurfaceColor c) { + Map op = op("fillRoundRect", c); + op.put("x", Float.valueOf(x)); + op.put("y", Float.valueOf(y)); + op.put("w", Float.valueOf(w)); + op.put("h", Float.valueOf(h)); + op.put("corner", Float.valueOf(corner)); + return add(op); + } + + /// Fills an ellipse. + /// + /// #### Parameters + /// + /// - `cx`: center x in view-box units + /// - `cy`: center y in view-box units + /// - `rx`: horizontal radius in view-box units + /// - `ry`: vertical radius in view-box units + /// - `c`: the fill color + /// + /// #### Returns + /// + /// this vector node, for chaining + public SurfaceVector fillEllipse(float cx, float cy, float rx, float ry, SurfaceColor c) { + Map op = op("fillEllipse", c); + putEllipse(op, cx, cy, rx, ry); + return add(op); + } + + /// Fills a pie slice: the elliptical arc between the two angles joined to the center. Angles + /// use the clock convention (degrees, 0 = 12 o'clock, clockwise positive). + /// + /// #### Parameters + /// + /// - `cx`: center x in view-box units + /// - `cy`: center y in view-box units + /// - `rx`: horizontal radius in view-box units + /// - `ry`: vertical radius in view-box units + /// - `startDeg`: the start angle in clock degrees + /// - `sweepDeg`: the sweep in degrees, clockwise positive + /// - `c`: the fill color + /// + /// #### Returns + /// + /// this vector node, for chaining + public SurfaceVector fillArc(float cx, float cy, float rx, float ry, float startDeg, + float sweepDeg, SurfaceColor c) { + Map op = op("fillArc", c); + putEllipse(op, cx, cy, rx, ry); + op.put("start", Float.valueOf(startDeg)); + op.put("sweep", Float.valueOf(sweepDeg)); + return add(op); + } + + /// Strokes the outline of an ellipse. + /// + /// #### Parameters + /// + /// - `cx`: center x in view-box units + /// - `cy`: center y in view-box units + /// - `rx`: horizontal radius in view-box units + /// - `ry`: vertical radius in view-box units + /// - `strokeWidth`: the stroke width in view-box units + /// - `c`: the stroke color + /// + /// #### Returns + /// + /// this vector node, for chaining + public SurfaceVector strokeEllipse(float cx, float cy, float rx, float ry, float strokeWidth, + SurfaceColor c) { + Map op = op("strokeEllipse", c); + putEllipse(op, cx, cy, rx, ry); + op.put("sw", Float.valueOf(strokeWidth)); + return add(op); + } + + /// Strokes an open elliptical arc with round caps -- the primitive for gauge tracks and + /// progress rings. Angles use the clock convention (degrees, 0 = 12 o'clock, clockwise + /// positive). + /// + /// #### Parameters + /// + /// - `cx`: center x in view-box units + /// - `cy`: center y in view-box units + /// - `rx`: horizontal radius in view-box units + /// - `ry`: vertical radius in view-box units + /// - `startDeg`: the start angle in clock degrees + /// - `sweepDeg`: the sweep in degrees, clockwise positive + /// - `strokeWidth`: the stroke width in view-box units + /// - `c`: the stroke color + /// + /// #### Returns + /// + /// this vector node, for chaining + public SurfaceVector strokeArc(float cx, float cy, float rx, float ry, float startDeg, + float sweepDeg, float strokeWidth, SurfaceColor c) { + Map op = op("strokeArc", c); + putEllipse(op, cx, cy, rx, ry); + op.put("start", Float.valueOf(startDeg)); + op.put("sweep", Float.valueOf(sweepDeg)); + op.put("sw", Float.valueOf(strokeWidth)); + return add(op); + } + + /// Draws a line with round caps. + /// + /// #### Parameters + /// + /// - `x1`: start x in view-box units + /// - `y1`: start y in view-box units + /// - `x2`: end x in view-box units + /// - `y2`: end y in view-box units + /// - `strokeWidth`: the stroke width in view-box units + /// - `c`: the stroke color + /// + /// #### Returns + /// + /// this vector node, for chaining + public SurfaceVector line(float x1, float y1, float x2, float y2, float strokeWidth, + SurfaceColor c) { + Map op = op("line", c); + op.put("x1", Float.valueOf(x1)); + op.put("y1", Float.valueOf(y1)); + op.put("x2", Float.valueOf(x2)); + op.put("y2", Float.valueOf(y2)); + op.put("sw", Float.valueOf(strokeWidth)); + return add(op); + } + + /// Fills a polygon built from x,y coordinate pairs. The path is implicitly closed for + /// filling regardless of `close`. + /// + /// #### Parameters + /// + /// - `xy`: coordinate pairs `x0, y0, x1, y1, ...` in view-box units, at least three points + /// - `close`: whether the serialized path is marked closed (kept for renderer symmetry with + /// `strokePath`) + /// - `c`: the fill color + /// + /// #### Returns + /// + /// this vector node, for chaining + public SurfaceVector fillPath(float[] xy, boolean close, SurfaceColor c) { + Map op = op("fillPath", c); + op.put("pts", copyPoints(xy)); + op.put("close", Boolean.valueOf(close)); + return add(op); + } + + /// Strokes a poly-line built from x,y coordinate pairs, with round caps and joins. + /// + /// #### Parameters + /// + /// - `xy`: coordinate pairs `x0, y0, x1, y1, ...` in view-box units, at least two points + /// - `close`: whether the last point connects back to the first + /// - `strokeWidth`: the stroke width in view-box units + /// - `c`: the stroke color + /// + /// #### Returns + /// + /// this vector node, for chaining + public SurfaceVector strokePath(float[] xy, boolean close, float strokeWidth, SurfaceColor c) { + Map op = op("strokePath", c); + op.put("pts", copyPoints(xy)); + op.put("close", Boolean.valueOf(close)); + op.put("sw", Float.valueOf(strokeWidth)); + return add(op); + } + + /// Draws text anchored at the middle of `x` with its baseline at `y`. The text supports + /// `${key}` interpolation from the entry state map, like `SurfaceText`. + /// + /// #### Parameters + /// + /// - `text`: the text, may embed `${key}` placeholders + /// - `x`: the horizontal anchor (text centers on it) in view-box units + /// - `y`: the text baseline in view-box units + /// - `fontSize`: the font size in view-box units + /// - `w`: the font weight + /// - `c`: the text color + /// + /// #### Returns + /// + /// this vector node, for chaining + public SurfaceVector text(String text, float x, float y, float fontSize, SurfaceFontWeight w, + SurfaceColor c) { + if (text == null) { + throw new IllegalArgumentException("Vector text must not be null"); + } + Map op = op("text", c); + op.put("text", text); + op.put("x", Float.valueOf(x)); + op.put("y", Float.valueOf(y)); + op.put("size", Float.valueOf(fontSize)); + op.put("fw", w == null ? SurfaceFontWeight.REGULAR.getJsonName() : w.getJsonName()); + return add(op); + } + + /// Opens a rotation group with a fixed angle: every op added until the matching + /// `endRotation()` rotates by `degrees` (clock convention: clockwise positive) around the + /// pivot. Groups nest. + /// + /// #### Parameters + /// + /// - `degrees`: the rotation in degrees, clockwise positive + /// - `pivotX`: the pivot x in view-box units + /// - `pivotY`: the pivot y in view-box units + /// + /// #### Returns + /// + /// this vector node, for chaining + public SurfaceVector beginRotation(float degrees, float pivotX, float pivotY) { + Map op = rotationOp(pivotX, pivotY); + op.put("deg", Float.valueOf(degrees)); + return openRotation(op); + } + + /// Opens a rotation group whose angle is read from the entry state map: the state value is a + /// `Number` in degrees, clockwise positive. This is how clock hands and gauge needles animate + /// -- a per-entry timeline updates the angle without republishing the layout. + /// + /// #### Parameters + /// + /// - `degreesStateKey`: the state-map key holding the angle in degrees + /// - `pivotX`: the pivot x in view-box units + /// - `pivotY`: the pivot y in view-box units + /// + /// #### Returns + /// + /// this vector node, for chaining + public SurfaceVector beginRotation(String degreesStateKey, float pivotX, float pivotY) { + if (degreesStateKey == null || degreesStateKey.length() == 0) { + throw new IllegalArgumentException("The rotation state key must not be empty"); + } + Map op = rotationOp(pivotX, pivotY); + op.put("degKey", degreesStateKey); + return openRotation(op); + } + + /// Closes the most recently opened rotation group. + /// + /// #### Returns + /// + /// this vector node, for chaining + public SurfaceVector endRotation() { + if (targets.size() == 1) { + throw new IllegalStateException("endRotation() without a matching beginRotation(); " + + "rotation groups must be balanced"); + } + targets.remove(targets.size() - 1); + return this; + } + + /// Returns the logical view-box width. + public int getViewBoxWidth() { + return viewBoxWidth; + } + + /// Returns the logical view-box height. + public int getViewBoxHeight() { + return viewBoxHeight; + } + + /// Returns the number of recorded operations, rotation groups included. + public int getOpCount() { + return opCount; + } + + @Override + public SurfaceVector setPadding(int all) { + super.setPadding(all); + return this; + } + + @Override + public SurfaceVector setPadding(int top, int right, int bottom, int left) { + super.setPadding(top, right, bottom, left); + return this; + } + + @Override + public SurfaceVector setBackground(SurfaceColor background) { + super.setBackground(background); + return this; + } + + @Override + public SurfaceVector setCornerRadius(int radius) { + super.setCornerRadius(radius); + return this; + } + + @Override + public SurfaceVector setAlignment(SurfaceAlignment alignment) { + super.setAlignment(alignment); + return this; + } + + @Override + public SurfaceVector setWeight(int weight) { + super.setWeight(weight); + return this; + } + + @Override + public SurfaceVector setSize(int widthDips, int heightDips) { + super.setSize(widthDips, heightDips); + return this; + } + + @Override + public SurfaceVector setAction(String actionId) { + super.setAction(actionId); + return this; + } + + @Override + public SurfaceVector setAction(String actionId, Map params) { + super.setAction(actionId, params); + return this; + } + + @Override + String getType() { + return "vec"; + } + + @Override + void serializeContent(Map out, Map images, int depth) { + if (targets.size() != 1) { + throw new IllegalStateException("A SurfaceVector has " + (targets.size() - 1) + + " unclosed rotation group(s); every beginRotation() needs a matching " + + "endRotation() before the descriptor serializes"); + } + if (opCount > MAX_OPS) { + throw new IllegalArgumentException("A SurfaceVector is limited to " + MAX_OPS + + " drawing operations so widget renderers stay within their budgets; this " + + "node has " + opCount); + } + out.put("vw", Integer.valueOf(viewBoxWidth)); + out.put("vh", Integer.valueOf(viewBoxHeight)); + out.put("ops", serializeOps(ops)); + } + + // --- internals -------------------------------------------------------------- + + private static List serializeOps(List> src) { + List out = new ArrayList(src.size()); + for (Map op : src) { + Map m = new LinkedHashMap(); + for (Map.Entry e : op.entrySet()) { + Object v = e.getValue(); + if (v instanceof Float) { + m.put(e.getKey(), Double.valueOf(((Float) v).doubleValue())); + } else if (v instanceof SurfaceColor) { + m.put(e.getKey(), SurfaceSerializer.colorMap((SurfaceColor) v)); + } else if (v instanceof float[]) { + float[] pts = (float[]) v; + List list = new ArrayList(pts.length); + for (float pt : pts) { + list.add(Double.valueOf(pt)); + } + m.put(e.getKey(), list); + } else if (v instanceof List) { + // the nested op list of a rotation group + m.put(e.getKey(), serializeOps(castOps(v))); + } else { + m.put(e.getKey(), v); + } + } + out.add(m); + } + return out; + } + + @SuppressWarnings("unchecked") + private static List> castOps(Object v) { + return (List>) v; + } + + private static Map op(String name, SurfaceColor c) { + if (c == null) { + throw new IllegalArgumentException("Every vector op needs a color"); + } + Map op = new LinkedHashMap(); + op.put("o", name); + op.put("c", c); + return op; + } + + private static void putEllipse(Map op, float cx, float cy, float rx, + float ry) { + op.put("cx", Float.valueOf(cx)); + op.put("cy", Float.valueOf(cy)); + op.put("rx", Float.valueOf(rx)); + op.put("ry", Float.valueOf(ry)); + } + + private static float[] copyPoints(float[] xy) { + if (xy == null || xy.length < 4 || xy.length % 2 != 0) { + throw new IllegalArgumentException("Path points must be x,y pairs with at least " + + "two points"); + } + float[] copy = new float[xy.length]; + System.arraycopy(xy, 0, copy, 0, xy.length); + return copy; + } + + private static Map rotationOp(float pivotX, float pivotY) { + Map op = new LinkedHashMap(); + op.put("o", "rot"); + op.put("px", Float.valueOf(pivotX)); + op.put("py", Float.valueOf(pivotY)); + return op; + } + + private SurfaceVector openRotation(Map op) { + List> nested = new ArrayList>(); + op.put("ops", nested); + add(op); + targets.add(nested); + return this; + } + + private SurfaceVector add(Map op) { + targets.get(targets.size() - 1).add(op); + opCount++; + return this; + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/Surfaces.java b/CodenameOne/src/com/codename1/surfaces/Surfaces.java new file mode 100644 index 00000000000..1b41a53dc01 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/Surfaces.java @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import com.codename1.io.Log; +import com.codename1.surfaces.spi.SurfaceBridge; +import com.codename1.ui.Display; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// The static entry point for external surfaces: home-screen widgets and live activities -- the +/// two faces of one concept, a live source of information that resides outside your app. Declare +/// your widget kinds and register an action handler in `init()`, then publish content whenever +/// your data changes: +/// +/// ```java +/// Surfaces.registerWidgetKind(new WidgetKind("delivery_status") +/// .setDisplayName("Delivery").setDescription("Track your order")); +/// Surfaces.setActionHandler(evt -> showOrder(evt.getParams())); +/// ... +/// Surfaces.publish("delivery_status", new WidgetTimeline() +/// .setContent(layout).addEntry(new Date(), state)); +/// ``` +/// +/// #### How surfaces render +/// +/// Surfaces render while your app process may be dead: the published timeline is serialized (a +/// JSON descriptor plus PNG blobs) and persisted where the platform renderer can reach it -- the +/// iOS widget extension, the Android widget provider or a desktop surface window. Layouts embed +/// `${key}` placeholders resolved from each timeline entry's state map, and `SurfaceDynamicText` +/// countdowns tick natively on the OS clock with no app wakeups. To refresh content periodically +/// implement `com.codename1.background.BackgroundFetch` and re-publish there. +/// +/// Widget kinds must also be declared at build time in the project's `surfaces.json` resource -- +/// the platform widget galleries are compiled into the native app. See the package documentation. +/// +/// #### Zero cost when unused +/// +/// Merely referencing this package makes the build inject the native plumbing (the WidgetKit +/// extension and app group on iOS, the widget receivers on Android). Apps that never touch +/// `com.codename1.surfaces` get none of it. On the simulator the Widgets preview window renders +/// published surfaces; on unsupported ports the API is an inert no-op. +public final class Surfaces { + private static SurfaceBridge bridge; + private static boolean bridgeOverridden; + private static SurfaceActionHandler actionHandler; + private static final List pendingActions = + new ArrayList(); + private static final List registeredKinds = new ArrayList(); + + private Surfaces() { + } + + /// Returns true when this platform can render home-screen (or desktop) widgets. + /// + /// #### Returns + /// + /// true when widgets are supported + public static boolean areWidgetsSupported() { + SurfaceBridge b = bridgeInternal(); + return b != null && b.areWidgetsSupported(); + } + + /// Declares a widget kind at runtime. Call once per kind, typically from `init()`. The id must + /// match a kind declared in the project's `surfaces.json` build-time manifest; a mismatch logs + /// a prominent warning on supporting platforms. + /// + /// #### Parameters + /// + /// - `kind`: the kind declaration + public static void registerWidgetKind(WidgetKind kind) { + if (kind == null) { + return; + } + for (WidgetKind k : registeredKinds) { + if (k.getId().equals(kind.getId())) { + registeredKinds.remove(k); + break; + } + } + registeredKinds.add(kind); + SurfaceBridge b = bridgeInternal(); + if (b != null) { + b.registerWidgetKind(SurfaceSerializer.serializeKind(kind)); + } + } + + /// Returns the widget kinds registered so far. + public static List getRegisteredKinds() { + return new ArrayList(registeredKinds); + } + + /// Publishes a widget kind's content, atomically replacing any previously published timeline + /// and asking the platform to re-render the kind's widget instances. A no-op on platforms + /// without widget support. + /// + /// #### Threading + /// + /// Callable from any thread -- including + /// `com.codename1.background.BackgroundFetch#performBackgroundFetch(long, com.codename1.util.Callback)` + /// callbacks while the app UI is not running (on Android the fetch runs in a background + /// service with no Activity at all). Publishing is data-only: the timeline is serialized, + /// persisted where the platform renderer can reach it and the renderer is poked + /// asynchronously; no step blocks on the EDT or the platform UI thread. Implementing + /// background fetch and re-publishing there is the intended way to keep widgets fresh; see + /// the `com.codename1.surfaces.spi` package documentation for the per-platform background + /// update story. + /// + /// #### Parameters + /// + /// - `kindId`: the widget kind id + /// - `timeline`: the content to publish + public static void publish(String kindId, WidgetTimeline timeline) { + SurfaceBridge b = bridgeInternal(); + if (b == null || !b.areWidgetsSupported()) { + return; + } + Map images = new LinkedHashMap(); + String json = SurfaceSerializer.serializeTimeline(kindId, timeline, images); + b.publishWidgetTimeline(kindId, json, images); + } + + /// Asks the platform to re-render widgets from their already-published timelines. + /// + /// #### Parameters + /// + /// - `kindId`: the kind to reload, or null for all kinds + public static void reloadWidgets(String kindId) { + SurfaceBridge b = bridgeInternal(); + if (b != null) { + b.reloadWidgets(kindId); + } + } + + /// Returns the number of widget instances of a kind the user placed on the platform surface, + /// or 0 when none exist or the platform cannot tell. Useful to skip publishing work when no + /// widget is installed. + /// + /// #### Parameters + /// + /// - `kindId`: the widget kind id + /// + /// #### Returns + /// + /// the installed instance count, or 0 + public static int getInstalledWidgetCount(String kindId) { + SurfaceBridge b = bridgeInternal(); + return b == null ? 0 : b.getInstalledWidgetCount(kindId); + } + + /// Registers the single handler receiving surface action events on the EDT. Registration + /// flushes any actions queued before it (e.g. the tap that cold-started the app), in arrival + /// order, with their cold-start flag set. + /// + /// #### Parameters + /// + /// - `handler`: the handler, or null to clear + public static void setActionHandler(SurfaceActionHandler handler) { + // Install the handler and drain the cold-start queue atomically under the same lock + // dispatchAction() uses, so an in-flight dispatch cannot observe a null handler and then + // enqueue an event after this method already flushed an empty queue (which would strand + // it forever). Either dispatch's read-and-enqueue happens entirely before this install + // (the event is drained here) or entirely after (dispatch sees the handler and delivers). + List queued = null; + synchronized (pendingActions) { + actionHandler = handler; + if (handler != null && !pendingActions.isEmpty()) { + queued = new ArrayList(pendingActions); + pendingActions.clear(); + } + } + if (queued != null) { + for (SurfaceActionEvent evt : queued) { + deliver(handler, evt); + } + } + } + + // --- framework/port entry points ----------------------------------------- + + /// Framework/port entry point: delivers a surface action to the app. Ports call this after + /// decoding their platform payload (deep link, intent extras, window click). Handles EDT + /// marshaling; when no handler is registered yet the event is queued and flagged cold start. + /// + /// #### Parameters + /// + /// - `source`: the widget kind id or live activity type + /// - `actionId`: the action id of the tapped node + /// - `params`: the action parameters, may be null + public static void dispatchAction(String source, String actionId, Map params) { + SurfaceActionEvent evt = new SurfaceActionEvent(source, actionId, params); + SurfaceActionHandler h; + // Read the handler and decide queue-vs-deliver atomically under the same lock + // setActionHandler() installs it and drains under -- see the note there. + synchronized (pendingActions) { + h = actionHandler; + if (h == null) { + evt.setColdStart(true); + pendingActions.add(evt); + return; + } + } + deliver(h, evt); + } + + /// Framework/port/test entry point: overrides the bridge resolved from the platform port. + /// Passing null restores platform resolution. + /// + /// #### Parameters + /// + /// - `b`: the bridge, or null to resolve from the platform again + public static void setBridge(SurfaceBridge b) { + bridge = b; + bridgeOverridden = b != null; + } + + static SurfaceBridge bridgeInternal() { + if (bridgeOverridden) { + return bridge; + } + if (!Display.isInitialized()) { + return null; + } + try { + return Display.getInstance().getSurfaceBridge(); + } catch (Throwable t) { + Log.e(t); + return null; + } + } + + /// Test seam: clears the bridge override, handler, queued actions and registered kinds. + static void reset() { + bridge = null; + bridgeOverridden = false; + synchronized (pendingActions) { + actionHandler = null; + pendingActions.clear(); + } + registeredKinds.clear(); + } + + private static void deliver(final SurfaceActionHandler h, final SurfaceActionEvent evt) { + if (h == null) { + return; + } + if (Display.isInitialized()) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + h.onSurfaceAction(evt); + } + }); + } else { + h.onSurfaceAction(evt); + } + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/WidgetKind.java b/CodenameOne/src/com/codename1/surfaces/WidgetKind.java new file mode 100644 index 00000000000..083436e70f0 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/WidgetKind.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import java.util.ArrayList; +import java.util.List; + +/// Declares one kind of home-screen widget the app offers (an app may offer several). The kind id +/// must match an entry in the `surfaces.json` file kept in the project's resources: widget kinds +/// are compiled into the native app (the widget gallery entries, the iOS widget bundle and the +/// Android receivers are all static), so the build needs them at build time while this runtime +/// declaration drives publishing and validation. A mismatch logs a prominent warning. +/// +/// Ids are restricted to `[a-z][a-z0-9_]*` because they are used to derive native type and +/// resource names. +public class WidgetKind { + private final String id; + private String displayName; + private String description; + private final List supportedSizes = new ArrayList(); + + /// Creates a widget kind declaration. + /// + /// #### Parameters + /// + /// - `id`: the stable kind identifier, `[a-z][a-z0-9_]*` + public WidgetKind(String id) { + if (id == null || !isValidId(id)) { + throw new IllegalArgumentException("Widget kind ids must match [a-z][a-z0-9_]* but got: " + id); + } + this.id = id; + } + + private static boolean isValidId(String id) { + int n = id.length(); + if (n == 0) { + return false; + } + char first = id.charAt(0); + if (first < 'a' || first > 'z') { + return false; + } + for (int i = 1; i < n; i++) { + char c = id.charAt(i); + boolean ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; + if (!ok) { + return false; + } + } + return true; + } + + /// Sets the title shown in the platform widget gallery. + /// + /// #### Parameters + /// + /// - `displayName`: the gallery title + /// + /// #### Returns + /// + /// this kind, for chaining + public WidgetKind setDisplayName(String displayName) { + this.displayName = displayName; + return this; + } + + /// Sets the description shown in the platform widget gallery. + /// + /// #### Parameters + /// + /// - `description`: the gallery description + /// + /// #### Returns + /// + /// this kind, for chaining + public WidgetKind setDescription(String description) { + this.description = description; + return this; + } + + /// Adds a supported size family. When no size is added the kind defaults to `SMALL` and + /// `MEDIUM`. + /// + /// #### Parameters + /// + /// - `size`: the size family to support + /// + /// #### Returns + /// + /// this kind, for chaining + public WidgetKind addSupportedSize(WidgetSize size) { + if (size != null && !supportedSizes.contains(size)) { + supportedSizes.add(size); + } + return this; + } + + /// Returns the stable kind identifier. + public String getId() { + return id; + } + + /// Returns the gallery title, or null. + public String getDisplayName() { + return displayName; + } + + /// Returns the gallery description, or null. + public String getDescription() { + return description; + } + + /// Returns the supported size families; `SMALL` and `MEDIUM` when none were added explicitly. + public List getSupportedSizes() { + if (supportedSizes.isEmpty()) { + List def = new ArrayList(2); + def.add(WidgetSize.SMALL); + def.add(WidgetSize.MEDIUM); + return def; + } + return supportedSizes; + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/WidgetSize.java b/CodenameOne/src/com/codename1/surfaces/WidgetSize.java new file mode 100644 index 00000000000..d4f3a8fa288 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/WidgetSize.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.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. +public enum WidgetSize { + SMALL("small"), + MEDIUM("medium"), + LARGE("large"), + LOCKSCREEN("lockscreen"); + + private final String jsonName; + + WidgetSize(String jsonName) { + this.jsonName = jsonName; + } + + /// Returns the wire-format name used in the serialized descriptor. + public String getJsonName() { + return jsonName; + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java b/CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java new file mode 100644 index 00000000000..e59f846a209 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/WidgetTimeline.java @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/// The content of a widget kind: a layout descriptor plus a timeline of dated state snapshots. +/// Publishing a timeline lets the widget change over time without waking the app -- the OS (or the +/// desktop surface) switches to the entry whose date has most recently passed and interpolates its +/// state map into the layout's `${key}` placeholders: +/// +/// ```java +/// WidgetTimeline t = new WidgetTimeline() +/// .setContent(deliveryLayout) +/// .addEntry(now, stateMap("Out for delivery", eta, 0.7f)) +/// .addEntry(eta, stateMap("Arriving now", eta, 1.0f)); +/// Surfaces.publish("delivery_status", t); +/// ``` +/// +/// State values may be `String`, `Number`, `Boolean`, or `Long` epoch millis for the date keys of +/// `SurfaceDynamicText` / `SurfaceProgress`. A timeline published without entries gets a single +/// implicit entry effective immediately with an empty state map. +public class WidgetTimeline { + /// After the last entry passes, ask the platform to request fresh content (the app's + /// `BackgroundFetch` is the refresh hook). Maps to WidgetKit's `.atEnd` policy. + public static final int RELOAD_AT_END = 0; + + /// Keep showing the last entry until the app publishes again. + public static final int RELOAD_NEVER = 1; + + /// One dated state snapshot within a timeline. + public static class Entry { + private final Date date; + private final Map state; + + Entry(Date date, Map state) { + this.date = date; + this.state = state; + } + + /// Returns the moment this entry becomes the widget's content. + public Date getDate() { + return date; + } + + /// Returns the state map interpolated into the layout, never null. + public Map getState() { + return state; + } + } + + private SurfaceNode defaultContent; + private SurfaceNode smallContent; + private SurfaceNode mediumContent; + private SurfaceNode largeContent; + private SurfaceNode lockscreenContent; + private final List entries = new ArrayList(); + private int reloadPolicy = RELOAD_AT_END; + + /// Sets the layout used for every size family that has no explicit override. + /// + /// #### Parameters + /// + /// - `root`: the layout root node + /// + /// #### Returns + /// + /// this timeline, for chaining + public WidgetTimeline setContent(SurfaceNode root) { + this.defaultContent = root; + return this; + } + + /// Sets a size-specific layout override. + /// + /// #### Parameters + /// + /// - `size`: the size family the layout applies to + /// - `root`: the layout root node + /// + /// #### Returns + /// + /// 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; + } + return this; + } + + /// Appends a dated state snapshot. Entries may be added in any order; they are sorted by date + /// when published. + /// + /// #### Parameters + /// + /// - `date`: the moment the entry becomes current + /// - `state`: the state map interpolated into the layout, may be null + /// + /// #### Returns + /// + /// this timeline, for chaining + public WidgetTimeline addEntry(Date date, Map state) { + if (date == null) { + throw new IllegalArgumentException("Timeline entries need a date"); + } + Map s = state; + if (s == null) { + s = new java.util.HashMap(); + } + entries.add(new Entry(date, s)); + return this; + } + + /// Sets what happens after the last entry passes. + /// + /// #### Parameters + /// + /// - `policy`: `RELOAD_AT_END` or `RELOAD_NEVER` + /// + /// #### Returns + /// + /// this timeline, for chaining + public WidgetTimeline setReloadPolicy(int policy) { + this.reloadPolicy = policy; + return this; + } + + /// Returns the layout for the given size family: the explicit override when one was set, + /// otherwise the default content. + /// + /// #### Parameters + /// + /// - `size`: the size family + /// + /// #### Returns + /// + /// 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; + } + 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; + } + } + + /// Returns the layout used for size families without an explicit override, or null. + public SurfaceNode getDefaultContent() { + return defaultContent; + } + + /// Returns the entries in the order they were added. + public List getEntries() { + return entries; + } + + /// Returns the reload policy, `RELOAD_AT_END` or `RELOAD_NEVER`. + public int getReloadPolicy() { + return reloadPolicy; + } +} diff --git a/CodenameOne/src/com/codename1/surfaces/package-info.java b/CodenameOne/src/com/codename1/surfaces/package-info.java new file mode 100644 index 00000000000..9ed35cb23bf --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/package-info.java @@ -0,0 +1,133 @@ +/* + * 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. + */ + +/// External surfaces: home-screen widgets and live activities from one declarative API. +/// +/// Both features answer the same developer question -- *how do I keep a live source of +/// information outside my app?* -- so Codename One models them as one concept. A **widget** is +/// persistent, user-placed and content-driven (weather, next meeting, delivery status on the home +/// screen). A **live activity** is transient, app-started and progress-driven (the delivery that +/// is out RIGHT NOW, a running timer, a live score on the iOS lock screen / Dynamic Island, an +/// ongoing Android notification, a floating desktop pill). They share the layout model, the +/// serialization, the state mechanism and the action model. +/// +/// #### The dead-process rule +/// +/// Surfaces render while your app process may not be running. Everything you hand this API is +/// therefore turned into plain data at publish time: layouts serialize to a compact JSON +/// descriptor, images are encoded to named PNG blobs, and "callbacks" are string action ids that +/// open the app and are delivered to your `SurfaceActionHandler` on the EDT (queued across a cold +/// start). Layout text embeds `${key}` placeholders resolved from a per-entry state map, so +/// content changes are cheap re-publishes of data, not layouts. +/// +/// #### The layout model +/// +/// A small sealed catalog of nodes every platform can render natively -- `SurfaceColumn`, +/// `SurfaceRow`, `SurfaceBox`, `SurfaceText`, `SurfaceDynamicText`, `SurfaceImage`, +/// `SurfaceProgress`, `SurfaceVector`, `SurfaceSpacer` -- with shared styling (padding, +/// background, corner radius, alignment, weight, size, action). `SurfaceDynamicText` is the +/// headline feature: countdowns and elapsed-time text the OS animates on its own clock, so a +/// delivery ETA ticks every second with zero app wakeups. `SurfaceVector` covers the widgets the +/// template catalog cannot express -- clocks, gauges, dials -- with a retained list of vector +/// drawing ops (fills, strokes, arcs, text, rotation groups) replayed natively by every renderer. +/// +/// #### The lowest common denominator contract +/// +/// Android app widgets (`RemoteViews`) are the constrained platform; the catalog is designed to +/// its floor and degrades as follows: +/// +/// | Node | iOS (SwiftUI) | Android (RemoteViews) | Caveat | +/// |---|---|---|---| +/// | Column/Row | VStack/HStack | LinearLayout | weight maps to layout_weight | +/// | Box | ZStack | FrameLayout | child alignment via 9-way enum | +/// | Text | Text | TextView | Android renders light/regular/medium as regular, semibold/bold as bold | +/// | DynamicText timer | Text(date, style:) | Chronometer | native on both | +/// | DynamicText time | Text(date, style: .time) | TextClock | native on both | +/// | DynamicText date/relative | native | static text | Android refreshes only on next update | +/// | Image | Image | ImageView | named PNG blobs, content-hash dedup | +/// | Progress linear | ProgressView | ProgressBar | value 0..1 or state key | +/// | Progress circular | Gauge | falls back to linear | Android widgets lack determinate circular | +/// | Progress date interval | native animation | frozen at refresh | iOS-only nicety | +/// | Vector | SwiftUI Canvas | bitmap rendered in-process | desktop: CN1 Graphics; Android renders vec nodes as raster, so very large vec nodes cost bitmap budget | +/// | Spacer | Spacer | weighted View | | +/// | corner radius | clipShape | background drawable | may render square below Android 12 | +/// | node action | Link / widgetURL | setOnClickPendingIntent | small iOS widgets honor only the root action | +/// +/// Descriptors are limited to 8 nesting levels; keep payloads (JSON + images) comfortably under +/// 200kb -- the iOS widget extension runs in about 30mb of memory and Android parcels rendered +/// widgets over a 1mb binder transaction. +/// +/// #### Example: an analog clock widget +/// +/// A `SurfaceVector` face plus per-minute timeline entries driving the hand angles. Angles use +/// the clock convention (degrees, 0 = 12 o'clock, clockwise positive); the OS flips the entries +/// on schedule, so the clock stays correct for an hour with zero app wakeups: +/// +/// ```java +/// SurfaceVector face = new SurfaceVector(200, 200) +/// .fillEllipse(100, 100, 96, 96, SurfaceColor.BACKGROUND) +/// .beginRotation("hourAngle", 100, 100) +/// .line(100, 100, 100, 52, 8, SurfaceColor.LABEL).endRotation() +/// .beginRotation("minuteAngle", 100, 100) +/// .line(100, 100, 100, 24, 5, SurfaceColor.ACCENT).endRotation(); +/// WidgetTimeline t = new WidgetTimeline().setContent(face); +/// for (int m = 0; m < 60; m++) { +/// int totalMinutes = hourOfDay * 60 + minute + m; +/// Map state = new HashMap(); +/// state.put("minuteAngle", totalMinutes % 60 * 6f); +/// state.put("hourAngle", totalMinutes % 720 * 0.5f); +/// t.addEntry(new Date(minuteStart + m * 60000L), state); +/// } +/// Surfaces.publish("analog_clock", t); +/// ``` +/// +/// #### Build-time manifest: `surfaces.json` +/// +/// Platform widget galleries are compiled into the native app, so widget kinds must be known at +/// build time. Keep a `surfaces.json` in your project resources (next to your icons) mirroring +/// your runtime `WidgetKind` registrations: +/// +/// ```json +/// { +/// "liveActivities": true, +/// "kinds": [ +/// { "id": "delivery_status", "name": "Delivery", "description": "Track your order", +/// "iosFamilies": ["systemSmall", "systemMedium"] } +/// ] +/// } +/// ``` +/// +/// #### Refresh and updates +/// +/// This version updates surfaces from the running app: publish a `WidgetTimeline` of dated +/// entries (the OS flips entries on schedule without waking you), implement +/// `com.codename1.background.BackgroundFetch` to re-publish periodically, and push live activity +/// state with `LiveActivity.update(...)`. Push-driven updates (server-sent widget content and +/// ActivityKit push tokens) are planned; the wire format already accommodates them. +/// +/// #### Zero cost when unused +/// +/// Referencing this package makes the build inject the native plumbing (WidgetKit extension and +/// app group on iOS, widget receivers on Android). Apps that never touch it get none of it. On +/// unsupported ports every entry point is an inert no-op. +package com.codename1.surfaces; diff --git a/CodenameOne/src/com/codename1/surfaces/spi/SurfaceBridge.java b/CodenameOne/src/com/codename1/surfaces/spi/SurfaceBridge.java new file mode 100644 index 00000000000..29126a119c7 --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/spi/SurfaceBridge.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces.spi; + +import java.util.Map; + +/// The platform seam of the external surfaces framework, implemented by ports and returned from +/// `CodenameOneImplementation.getSurfaceBridge()` (null on unsupported ports, making the whole +/// public API an inert no-op). +/// +/// Everything crosses this boundary as data -- JSON strings produced by the core serializer plus +/// named PNG blobs -- never as live model objects, because surfaces render while the app process +/// may be dead. Implementations MUST persist published payloads (shared container, files dir, +/// preferences) so the platform renderer can re-render them without the app running. +/// +/// Action events travel in the opposite direction: the port decodes its platform payload (deep +/// link, intent extras, window click) and calls `com.codename1.surfaces.Surfaces.dispatchAction`, +/// which handles EDT marshaling and cold-start queuing. +public interface SurfaceBridge { + /// Returns true when this port can render home-screen (or desktop) widgets. + boolean areWidgetsSupported(); + + /// Returns true when this port can present live activities (ongoing-state surfaces). + boolean isLiveActivitySupported(); + + /// Notifies the port of a runtime widget kind registration, letting it validate the kind + /// against what was compiled into the native app and prepare storage. + /// + /// #### Parameters + /// + /// - `kindJson`: the serialized kind declaration + void registerWidgetKind(String kindJson); + + /// Atomically replaces the persisted timeline of a widget kind and asks the platform to + /// re-render its widget instances. + /// + /// #### Parameters + /// + /// - `kindId`: the widget kind id + /// - `timelineJson`: the serialized timeline (layouts, entries, image names) + /// - `images`: PNG blobs keyed by registered name; may be empty, never null + void publishWidgetTimeline(String kindId, String timelineJson, Map images); + + /// Asks the platform to re-render widget instances from their persisted timelines. + /// + /// #### Parameters + /// + /// - `kindId`: the widget kind to reload, or null for all kinds + void reloadWidgets(String kindId); + + /// Returns the number of widget instances of a kind the user placed on the platform surface, + /// or 0 when none exist or the platform cannot tell. + /// + /// #### Parameters + /// + /// - `kindId`: the widget kind id + int getInstalledWidgetCount(String kindId); + + /// Starts a live activity. + /// + /// #### Parameters + /// + /// - `descriptorJson`: the serialized descriptor including the initial state + /// - `images`: PNG blobs keyed by registered name; may be empty, never null + /// + /// #### Returns + /// + /// a platform id for the running activity, or null when starting failed + String startLiveActivity(String descriptorJson, Map images); + + /// Updates a running live activity with a fresh state map; the platform re-interpolates the + /// existing layout locally. + /// + /// #### Parameters + /// + /// - `activityId`: the id returned from `startLiveActivity` + /// - `stateJson`: the serialized state map + void updateLiveActivity(String activityId, String stateJson); + + /// Ends a live activity. + /// + /// #### Parameters + /// + /// - `activityId`: the id returned from `startLiveActivity` + /// - `finalStateJson`: an optional final state shown before dismissal, or null + /// - `dismissImmediately`: true to remove the surface right away instead of letting the + /// platform linger on the final state + void endLiveActivity(String activityId, String finalStateJson, boolean dismissImmediately); +} diff --git a/CodenameOne/src/com/codename1/surfaces/spi/package-info.java b/CodenameOne/src/com/codename1/surfaces/spi/package-info.java new file mode 100644 index 00000000000..9ac9e00210b --- /dev/null +++ b/CodenameOne/src/com/codename1/surfaces/spi/package-info.java @@ -0,0 +1,73 @@ +/* + * 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. + */ + +/// The platform seam of the external surfaces framework. Application code never touches this +/// package -- ports implement `SurfaceBridge` and return it from +/// `CodenameOneImplementation.getSurfaceBridge()`. +/// +/// #### Port implementer notes +/// +/// - **Persist everything.** Surfaces render while the app process is dead. Published timeline +/// JSON and image blobs must be written where the platform renderer can reach them without the +/// VM: the app group container on iOS (`timeline.json` plus `.png` files under +/// `cn1surfaces//`), the files directory on Android, storage on desktop. Writes should +/// be atomic (write-rename) because the renderer may read concurrently. +/// - **Actions flow through the framework.** Decode your platform payload (deep link, intent +/// extras, window click) and call `com.codename1.surfaces.Surfaces.dispatchAction(source, +/// actionId, params)`; it handles EDT marshaling and cold-start queuing. The canonical deep +/// link form on URL-based platforms is +/// `cn1surface://a?src=&id=&p=`. +/// - **State-only updates.** `updateLiveActivity` ships just a state map; re-interpolate the +/// `${key}` placeholders of the descriptor persisted at start time and re-render locally. +/// - **Degrade honestly.** Return false from the `is...Supported` queries rather than presenting +/// a broken surface; the public API turns into a documented no-op. +/// +/// #### Updating surfaces from the background +/// +/// "A background process fetches data and updates the widget without the app UI running" is a +/// first-class flow: the app implements `com.codename1.background.BackgroundFetch`, registers by +/// calling `com.codename1.ui.Display.setPreferredBackgroundFetchInterval(int)` and re-publishes +/// inside `performBackgroundFetch`. To support it, +/// `com.codename1.surfaces.Surfaces.publish(String, com.codename1.surfaces.WidgetTimeline)` is +/// callable from any thread and no bridge may block on the EDT or the platform UI thread in its +/// publish path -- publishing is data-only (serialize, persist, poke the renderer +/// asynchronously). Rendering may still happen on the EDT later; that is a separate, async step. +/// +/// Per platform: +/// +/// - **Android**: the fetch runs in a background `IntentService` while no Activity exists; the +/// bridge resolves the service/application context, so publish works from a UI-less process. +/// The widget also pulls the app: when a provider renders an exhausted `reload=atEnd` timeline +/// (or no timeline at all) it starts the fetch service directly, throttled to once per 15 +/// minutes per kind, and only when the app declares background fetch. +/// - **iOS**: the widget extension cannot wake the app arbitrarily. A `reload=atEnd` timeline +/// makes WidgetKit re-request entries from the extension, which re-reads the persisted +/// document -- so publish timelines with enough future entries to bridge the gap between +/// fetches. Refresh the data itself from `performBackgroundFetch` (add `fetch` to the +/// `ios.background_modes` build hint); the publish path is file IO plus a +/// `WidgetCenter.reloadTimelines` call, neither of which requires the UIKit main thread. +/// - **Desktop / simulator**: the app process is running, so a publish simply re-renders the +/// surface windows. The simulator additionally simulates background fetch with a timer that +/// fires `performBackgroundFetch` while the app is paused, and the Widgets preview window +/// re-renders on every publish. +package com.codename1.surfaces.spi; diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 2960da32e62..73f14d476da 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -4590,6 +4590,18 @@ public com.codename1.car.spi.CarBridge getCarBridge() { return impl.getCarBridge(); } + /// 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 + /// directly. + /// + /// #### Returns + /// + /// the surface bridge, or null + public com.codename1.surfaces.spi.SurfaceBridge getSurfaceBridge() { + return impl.getSurfaceBridge(); + } + /// Returns true if the device has dialing capabilities /// /// #### Returns diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 1b26a84c9cc..d0cd0f34d03 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -5861,6 +5861,23 @@ public boolean isCarConnected() { return b != null && b.isConnected(); } + private com.codename1.surfaces.spi.SurfaceBridge surfaceBridge; + + @Override + public com.codename1.surfaces.spi.SurfaceBridge getSurfaceBridge() { + if (surfaceBridge == null) { + surfaceBridge = new com.codename1.impl.android.surfaces.AndroidSurfaceBridge(); + } + return surfaceBridge; + } + + /// Invoked once the app has started (from the generated stub, next to + /// `deliverPendingSharedContent`) to flush surface actions that arrived through the + /// `CN1SurfaceActionActivity` trampoline before the app instance existed. + public static void deliverPendingSurfaceActions() { + com.codename1.impl.android.surfaces.AndroidSurfaceBridge.deliverPendingActions(); + } + /** * Executes r on the UI thread and blocks the EDT to completion * @param r runnable to execute @@ -11959,6 +11976,23 @@ BackgroundFetch getBackgroundFetchListener() { } } + /** + * Returns the fully qualified class name of the app's background fetch listener, or null + * when the app does not implement {@link com.codename1.background.BackgroundFetch}. The + * surfaces plumbing persists this name on publish so a home screen widget that rendered an + * exhausted timeline can start {@link BackgroundFetchHandler} and let the app republish + * fresh content while no activity exists. + * + * @return the listener class name or null + */ + public static String getBackgroundFetchListenerClassName() { + if (instance == null) { + return null; + } + BackgroundFetch listener = instance.getBackgroundFetchListener(); + return listener == null ? null : listener.getClass().getName(); + } + public void scheduleLocalNotification(LocalNotification notif, long firstTime, int repeat) { if (android.os.Build.VERSION.SDK_INT >= 33) { if(!checkForPermission("android.permission.POST_NOTIFICATIONS", "This is required to receive notifications")){ diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java b/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java new file mode 100644 index 00000000000..30bad8715eb --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/AndroidSurfaceBridge.java @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android.surfaces; + +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import com.codename1.impl.android.AndroidImplementation; +import com.codename1.impl.android.AndroidNativeUtil; +import com.codename1.surfaces.Surfaces; +import com.codename1.surfaces.spi.SurfaceBridge; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/// The Android implementation of the surfaces SPI. Published timelines are persisted through +/// `CN1SurfaceStore` (widgets render from broadcast receivers while the app process may be dead) +/// and the matching generated provider -- class-name convention +/// `com.codename1.impl.android.CN1Widget_`, e.g. kind `delivery_status` maps to +/// `CN1Widget_DeliveryStatus` -- is poked with an `ACTION_APPWIDGET_UPDATE` broadcast. Live +/// activities lower to ongoing notifications through `CN1LiveActivityManager`. +/// +/// Surface taps arrive through `CN1SurfaceActionActivity`, are queued here and drained into +/// `com.codename1.surfaces.Surfaces.dispatchAction` (which performs its own EDT marshaling and +/// cold-start queuing); `AndroidImplementation.deliverPendingSurfaceActions()` re-drains after +/// the app finishes starting as a safety net. +public class AndroidSurfaceBridge implements SurfaceBridge { + private static final String TAG = "CN1Surfaces"; + private static final List pendingActions = new ArrayList(); + + @Override + public boolean areWidgetsSupported() { + return context() != null; + } + + @Override + public boolean isLiveActivitySupported() { + return CN1LiveActivityManager.isSupported(context()); + } + + @Override + public void registerWidgetKind(String kindJson) { + Context ctx = context(); + if (ctx == null) { + return; + } + try { + JSONObject kind = new JSONObject(kindJson); + String kindId = kind.optString("id", ""); + if (kindId.length() == 0) { + return; + } + CN1SurfaceStore.rememberKind(ctx, kindId); + // Validate the runtime registration against what was compiled into the app from + // surfaces.json: the generated provider must be a manifest-declared receiver. + ComponentName provider = providerComponent(ctx, kindId); + try { + ctx.getPackageManager().getReceiverInfo(provider, 0); + } catch (Exception missing) { + Log.e(TAG, "Widget kind '" + kindId + "' was registered at runtime but is not " + + "declared in surfaces.json; the build compiles widget kinds into the " + + "app, so this kind cannot appear in the widget gallery. Add it to " + + "surfaces.json and rebuild."); + } + } catch (Throwable t) { + Log.w(TAG, "Failed to register widget kind", t); + } + } + + @Override + public void publishWidgetTimeline(String kindId, String timelineJson, + Map images) { + Context ctx = context(); + if (ctx == null) { + return; + } + try { + CN1SurfaceStore.writeWidgetTimeline(ctx, kindId, timelineJson, images); + CN1SurfaceStore.rememberKind(ctx, kindId); + // remember the app's BackgroundFetch listener (null when the app declares none) + // so a widget rendering an exhausted timeline can pull fresh content itself + CN1SurfaceStore.rememberBackgroundFetchClass(ctx, + AndroidImplementation.getBackgroundFetchListenerClassName()); + broadcastUpdate(ctx, kindId); + } catch (Throwable t) { + Log.w(TAG, "Failed to publish the timeline of widget kind " + kindId, t); + } + } + + @Override + public void reloadWidgets(String kindId) { + Context ctx = context(); + if (ctx == null) { + return; + } + if (kindId != null) { + broadcastUpdate(ctx, kindId); + return; + } + for (String kind : CN1SurfaceStore.getRememberedKinds(ctx)) { + broadcastUpdate(ctx, kind); + } + } + + @Override + public int getInstalledWidgetCount(String kindId) { + Context ctx = context(); + if (ctx == null) { + return 0; + } + try { + AppWidgetManager mgr = AppWidgetManager.getInstance(ctx); + int[] ids = mgr.getAppWidgetIds(providerComponent(ctx, kindId)); + return ids == null ? 0 : ids.length; + } catch (Throwable t) { + return 0; + } + } + + @Override + public String startLiveActivity(String descriptorJson, Map images) { + return CN1LiveActivityManager.start(context(), descriptorJson, images); + } + + @Override + public void updateLiveActivity(String activityId, String stateJson) { + CN1LiveActivityManager.update(context(), activityId, stateJson); + } + + @Override + public void endLiveActivity(String activityId, String finalStateJson, + boolean dismissImmediately) { + CN1LiveActivityManager.end(context(), activityId, finalStateJson, dismissImmediately); + } + + // --- action delivery ------------------------------------------------------ + + /// Queues a surface action decoded by the trampoline activity and drains the queue + /// immediately; `Surfaces.dispatchAction` performs its own cold-start queuing so dispatching + /// as soon as the classes are loadable is safe. + public static void postAction(String source, String actionId, String paramsJson) { + synchronized (pendingActions) { + pendingActions.add(new String[]{source, actionId, paramsJson}); + } + deliverPendingActions(); + } + + /// Drains queued surface actions into the framework dispatcher. Invoked from `postAction` + /// and again from `AndroidImplementation.deliverPendingSurfaceActions()` once the app has + /// finished starting. + public static void deliverPendingActions() { + List drained; + synchronized (pendingActions) { + if (pendingActions.isEmpty()) { + return; + } + drained = new ArrayList(pendingActions); + pendingActions.clear(); + } + for (String[] action : drained) { + try { + Surfaces.dispatchAction(action[0], action[1], parseParams(action[2])); + } catch (Throwable t) { + Log.w(TAG, "Failed to dispatch a surface action", t); + } + } + } + + // --- helpers -------------------------------------------------------------- + + /// Maps a widget kind id to the simple name suffix of its generated provider class: + /// underscore-separated words become CamelCase (`delivery_status` -> `DeliveryStatus`). + /// The identical logic lives in the Android builder's widget codegen; keep them in sync. + static String toClassSuffix(String kindId) { + StringBuilder sb = new StringBuilder(kindId.length()); + boolean upper = true; + for (int i = 0; i < kindId.length(); i++) { + char c = kindId.charAt(i); + if (c == '_') { + upper = true; + continue; + } + if (upper) { + sb.append(Character.toUpperCase(c)); + upper = false; + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static ComponentName providerComponent(Context ctx, String kindId) { + return new ComponentName(ctx.getPackageName(), + "com.codename1.impl.android.CN1Widget_" + toClassSuffix(kindId)); + } + + private static void broadcastUpdate(Context ctx, String kindId) { + try { + ComponentName provider = providerComponent(ctx, kindId); + AppWidgetManager mgr = AppWidgetManager.getInstance(ctx); + int[] ids = mgr.getAppWidgetIds(provider); + if (ids == null || ids.length == 0) { + return; + } + Intent intent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE); + intent.setComponent(provider); + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids); + ctx.sendBroadcast(intent); + } catch (Throwable t) { + Log.w(TAG, "Failed to broadcast a widget update for kind " + kindId, t); + } + } + + private static Map parseParams(String paramsJson) { + if (paramsJson == null || paramsJson.length() == 0) { + return null; + } + try { + return toMap(new JSONObject(paramsJson)); + } catch (Throwable t) { + Log.w(TAG, "Failed to parse surface action parameters", t); + return null; + } + } + + private static Map toMap(JSONObject o) { + Map map = new HashMap(); + Iterator keys = o.keys(); + while (keys.hasNext()) { + String key = String.valueOf(keys.next()); + map.put(key, convert(o.opt(key))); + } + return map; + } + + private static Object convert(Object v) { + if (v instanceof JSONObject) { + return toMap((JSONObject) v); + } + if (v instanceof JSONArray) { + JSONArray a = (JSONArray) v; + List list = new ArrayList(a.length()); + for (int i = 0; i < a.length(); i++) { + list.add(convert(a.opt(i))); + } + return list; + } + if (v == JSONObject.NULL) { + return null; + } + return v; + } + + private static Context context() { + return AndroidNativeUtil.getContext(); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java new file mode 100644 index 00000000000..777038b5154 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -0,0 +1,279 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android.surfaces; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; +import android.util.Log; +import android.widget.RemoteViews; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.File; +import java.util.Map; + +/// Android lowering of live activities: an ongoing, silent, high-importance notification with +/// custom content views rendered by `CN1SurfaceRenderer` from the persisted descriptor. The +/// collapsed row approximates the Dynamic Island compact presentation (compactLeading + +/// compactTrailing composed into a row when the descriptor declares island regions, the full +/// content otherwise); the expanded notification shows the full content layout. +/// +/// Requires API 24 (`Notification.Builder#setCustomContentView` and +/// `DecoratedCustomViewStyle`); `AndroidSurfaceBridge#isLiveActivitySupported()` reports false +/// below that and when the user disabled notifications. Updates re-render locally from the +/// descriptor persisted at start time merged with the latest state map (state-only updates per +/// the SPI contract). Android 16 "Live Updates" / `ProgressStyle` is a possible future lowering. +public final class CN1LiveActivityManager { + private static final String TAG = "CN1Surfaces"; + private static final String DEFAULT_CHANNEL = "cn1_live_activities"; + private static final String NOTIFICATION_TAG = "cn1la"; + + private CN1LiveActivityManager() { + } + + /// Returns true when live activities can be presented on this device right now. + public static boolean isSupported(Context ctx) { + if (ctx == null || Build.VERSION.SDK_INT < 24) { + return false; + } + try { + NotificationManager nm = + (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); + return nm != null && nm.areNotificationsEnabled(); + } catch (Throwable t) { + return false; + } + } + + /// Starts a live activity from a serialized descriptor; returns its id or null on failure. + public static String start(Context ctx, String descriptorJson, Map images) { + if (!isSupported(ctx)) { + return null; + } + try { + String id = CN1SurfaceStore.newActivityId(ctx); + CN1SurfaceStore.writeLiveActivity(ctx, id, descriptorJson, images); + notifyActivity(ctx, id, new JSONObject(descriptorJson), true, false); + return id; + } catch (Throwable t) { + Log.w(TAG, "Failed to start a live activity", t); + return null; + } + } + + /// Re-renders a running live activity with a fresh state map replacing the previous one. + public static void update(Context ctx, String activityId, String stateJson) { + if (ctx == null || activityId == null) { + return; + } + try { + JSONObject doc = replaceState(ctx, activityId, stateJson); + if (doc == null) { + return; + } + CN1SurfaceStore.writeLiveActivity(ctx, activityId, doc.toString(), null); + notifyActivity(ctx, activityId, doc, true, false); + } catch (Throwable t) { + Log.w(TAG, "Failed to update live activity " + activityId, t); + } + } + + /// Ends a live activity, optionally leaving a dismissible final state on screen. + public static void end(Context ctx, String activityId, String finalStateJson, + boolean dismissImmediately) { + if (ctx == null || activityId == null) { + return; + } + try { + if (finalStateJson != null && !dismissImmediately) { + JSONObject doc = replaceState(ctx, activityId, finalStateJson); + if (doc != null) { + notifyActivity(ctx, activityId, doc, false, true); + } + } else { + NotificationManager nm = + (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm != null) { + nm.cancel(NOTIFICATION_TAG, notificationId(activityId)); + } + } + } catch (Throwable t) { + Log.w(TAG, "Failed to end live activity " + activityId, t); + } finally { + CN1SurfaceStore.deleteLiveActivity(ctx, activityId); + } + } + + // --- internals ------------------------------------------------------------ + + private static JSONObject replaceState(Context ctx, String activityId, String stateJson) + throws Exception { + String persisted = CN1SurfaceStore.readLiveActivity(ctx, activityId); + if (persisted == null) { + Log.w(TAG, "Live activity " + activityId + " is not running"); + return null; + } + JSONObject doc = new JSONObject(persisted); + if (stateJson != null) { + // each update carries the complete fresh state: replace wholesale so + // keys omitted by the app disappear, matching every other platform + doc.put("state", new JSONObject(stateJson)); + } + return doc; + } + + private static void notifyActivity(Context ctx, String activityId, JSONObject doc, + boolean ongoing, boolean autoCancel) { + if (Build.VERSION.SDK_INT < 24) { + return; + } + NotificationManager nm = + (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) { + return; + } + String type = doc.optString("type", "activity"); + JSONObject state = doc.optJSONObject("state"); + JSONObject content = doc.optJSONObject("content"); + File imagesDir = CN1SurfaceStore.liveActivityImagesDir(ctx, activityId); + + RemoteViews big = content == null ? null + : CN1SurfaceRenderer.render(ctx, content, state, type, imagesDir); + JSONObject compactNode = buildCompactNode(doc); + RemoteViews compact = compactNode == null ? big + : CN1SurfaceRenderer.render(ctx, compactNode, state, type, imagesDir); + if (big == null && compact == null) { + Log.w(TAG, "Live activity " + activityId + " has no renderable content"); + return; + } + + String channelId = channelId(doc); + ensureChannel(ctx, nm, channelId); + + Notification.Builder builder; + if (Build.VERSION.SDK_INT >= 26) { + builder = new Notification.Builder(ctx, channelId); + } else { + builder = new Notification.Builder(ctx); + builder.setPriority(Notification.PRIORITY_HIGH); + } + builder.setSmallIcon(smallIcon(ctx)); + builder.setStyle(new Notification.DecoratedCustomViewStyle()); + builder.setCustomContentView(compact != null ? compact : big); + if (big != null) { + builder.setCustomBigContentView(big); + } + builder.setOngoing(ongoing); + builder.setAutoCancel(autoCancel); + builder.setOnlyAlertOnce(true); + JSONObject tint = doc.optJSONObject("tint"); + if (tint != null && Build.VERSION.SDK_INT >= 21) { + long l = tint.optLong("l", 0xff007aff); + builder.setColor((int) l); + } + nm.notify(NOTIFICATION_TAG, notificationId(activityId), builder.build()); + } + + /// Composes the collapsed-row layout from the Dynamic Island compact regions when present: + /// leading, an expanding spacer, trailing. + private static JSONObject buildCompactNode(JSONObject doc) { + try { + JSONObject island = doc.optJSONObject("island"); + if (island == null) { + return null; + } + JSONObject leading = island.optJSONObject("compactLeading"); + JSONObject trailing = island.optJSONObject("compactTrailing"); + if (leading == null && trailing == null) { + return null; + } + JSONObject row = new JSONObject(); + row.put("t", "row"); + row.put("spacing", 8); + JSONArray ch = new JSONArray(); + if (leading != null) { + ch.put(leading); + } + JSONObject spacer = new JSONObject(); + spacer.put("t", "spacer"); + ch.put(spacer); + if (trailing != null) { + ch.put(trailing); + } + row.put("ch", ch); + return row; + } catch (Throwable t) { + Log.w(TAG, "Failed to compose the compact live activity row", t); + return null; + } + } + + private static String channelId(JSONObject doc) { + JSONObject android = doc.optJSONObject("android"); + if (android != null) { + String channel = android.optString("channel", null); + if (channel != null && channel.length() > 0) { + return channel; + } + } + return DEFAULT_CHANNEL; + } + + private static void ensureChannel(Context ctx, NotificationManager nm, String channelId) { + if (Build.VERSION.SDK_INT < 26) { + return; + } + try { + NotificationChannel channel = new NotificationChannel(channelId, "Live updates", + NotificationManager.IMPORTANCE_HIGH); + channel.setSound(null, null); + channel.setShowBadge(false); + channel.enableVibration(false); + nm.createNotificationChannel(channel); + } catch (Throwable t) { + Log.w(TAG, "Failed to create the live activity channel", t); + } + } + + /// Resolves the small icon the same way the port's notification publisher does. + private static int smallIcon(Context ctx) { + int smallIcon = ctx.getResources().getIdentifier("ic_stat_notify", "drawable", + ctx.getApplicationInfo().packageName); + if (smallIcon == 0) { + smallIcon = ctx.getResources().getIdentifier("icon", "drawable", + ctx.getApplicationInfo().packageName); + } + if (smallIcon == 0) { + smallIcon = ctx.getApplicationInfo().icon; + } + return smallIcon; + } + + private static int notificationId(String activityId) { + return activityId.hashCode(); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceActionActivity.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceActionActivity.java new file mode 100644 index 00000000000..82147576f98 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceActionActivity.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android.surfaces; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; + +/// Invisible trampoline receiving surface taps (widget nodes, live activity notifications). +/// Registered by the build with `Theme.NoDisplay`, it decodes the action extras, queues the +/// action with `AndroidSurfaceBridge` (which forwards to +/// `com.codename1.surfaces.Surfaces.dispatchAction` -- the framework queues internally until the +/// app registers a handler, so taps survive a cold start), brings the main activity forward the +/// same way `CodenameOneShareReceiverActivity` does, and finishes immediately. +public class CN1SurfaceActionActivity extends Activity { + /// Intent extra carrying the widget kind id or live activity type. + public static final String EXTRA_SOURCE = "CN1SurfaceSource"; + /// Intent extra carrying the action id of the tapped node. + public static final String EXTRA_ACTION_ID = "CN1SurfaceActionId"; + /// Intent extra carrying the action parameters as a JSON object string. + public static final String EXTRA_ACTION_PARAMS = "CN1SurfaceActionParams"; + private static final String TAG = "CN1Surfaces"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + try { + Intent intent = getIntent(); + if (intent != null) { + String actionId = intent.getStringExtra(EXTRA_ACTION_ID); + if (actionId != null) { + AndroidSurfaceBridge.postAction(intent.getStringExtra(EXTRA_SOURCE), + actionId, intent.getStringExtra(EXTRA_ACTION_PARAMS)); + } + } + } catch (Throwable t) { + Log.w(TAG, "Failed to decode a surface action", t); + } + launchMainActivity(); + finish(); + } + + private void launchMainActivity() { + try { + Intent launch = getPackageManager() + .getLaunchIntentForPackage(getApplicationInfo().packageName); + if (launch != null) { + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + startActivity(launch); + } + } catch (Throwable t) { + Log.w(TAG, "Failed to launch the main activity", t); + } + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java new file mode 100644 index 00000000000..67a5bbc3cdb --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceRenderer.java @@ -0,0 +1,978 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android.surfaces; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.Build; +import android.os.SystemClock; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.format.DateUtils; +import android.text.style.StyleSpan; +import android.util.Log; +import android.util.TypedValue; +import android.view.Gravity; +import android.widget.RemoteViews; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.File; +import java.lang.reflect.Method; +import java.text.DateFormat; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/// Renders a serialized surface descriptor node (the canonical JSON wire format produced by +/// `com.codename1.surfaces.SurfaceSerializer`) into a `RemoteViews` tree composed from the +/// pre-baked layout resources the build copies into the app (`res/layout/cn1_surface_*.xml`). +/// The renderer runs while the Codename One VM may not be initialized (widget updates, alarms), +/// so it only depends on the Android SDK and `org.json`. +/// +/// #### RemoteViews approximations of the LCD contract +/// +/// RemoteViews is the constrained floor of the surfaces catalog; the following approximations +/// are applied and documented here once for the whole Android lowering: +/// +/// - **weight**: `RemoteViews` cannot set `layout_weight` at runtime, so any child with +/// `weight >= 1` is wrapped in a pre-baked cell layout with `layout_weight="1"`. Relative +/// weights collapse to equal shares. A `spacer` without a `min` size uses the same weighted +/// cell. Weighted children expand reliably when their container is stretched by its parent +/// (the root always is); inside a container that wraps its content they may collapse to zero. +/// - **fixed size** (`w`/`h`): applied through `RemoteViews.setViewLayoutWidth/Height` on +/// API 31+ (invoked reflectively because the port compiles against an older `android.jar`); +/// ignored below API 31 except for images, which are decoded/scaled to the requested size. +/// - **alignment** (`align`): per-child gravity cannot be set through `RemoteViews`. Row +/// layouts are pre-baked with vertical-center gravity and box children stack top-leading. +/// On API 31+ (`LinearLayout.setGravity` became remotable in S) the first aligned child of a +/// column/row sets the gravity of the whole container; mixed alignments are not supported. +/// - **corner radius**: any non-zero radius uses the pre-baked 12dp rounded drawable with the +/// background color applied through `setBackgroundTintList` on API 31+; below API 31 the +/// background renders square (`setBackgroundColor`). +/// - **dynamic text**: `timerDown`/`timerUp` map to a native `Chronometer` (count-down needs +/// API 24; below that the remaining time renders as static text). `time` maps to `TextClock` +/// which always shows the *current* time. `date` and `relative` render as static text +/// computed at render time and refresh only on the next widget update. Font weight on +/// dynamic text is ignored (spans cannot be applied to OS-generated text). +/// - **progress**: determinate circular progress falls back to the linear bar. A date-interval +/// progress freezes its fraction at render time. Progress tint is applied on API 31+ only. +/// - **spacing**: `LinearLayout` has no runtime divider API, so container spacing becomes +/// leading padding on every child but the first. +/// - **dark mode**: colors resolve against the current `uiMode` at render time, so widgets +/// recolor on the next update after a light/dark switch (the OS does not re-render published +/// RemoteViews on configuration changes by itself). +/// - **images**: decoded from the published PNG blobs, downsampled so the longest edge is at +/// most 512px, with a cumulative budget of about 500kb of bitmap data per rendered tree +/// (RemoteViews parcels bitmaps over a 1mb binder transaction). Images beyond the budget are +/// skipped with a logged warning. +/// - **vector nodes**: RemoteViews has no drawing surface, so a `vec` node renders into an +/// in-process `Bitmap` via `Canvas`/`Paint`/`Path` (anti-aliased) shipped through an +/// ImageView; it shares the cumulative bitmap budget above and shrinks (or is skipped) when +/// the budget runs out. Angles arrive in the wire's clock convention (degrees, 0 = 12 +/// o'clock, clockwise positive); `Canvas.drawArc` measures degrees from 3 o'clock advancing +/// clockwise, so the conversion is `androidStart = clockStart - 90` with the sweep unchanged. +public final class CN1SurfaceRenderer { + private static final String TAG = "CN1Surfaces"; + private static final int MAX_DEPTH = 8; + private static final int MAX_BITMAP_DIMENSION = 512; + private static final int BITMAP_BUDGET_BYTES = 500 * 1024; + /// PendingIntent.FLAG_IMMUTABLE; declared locally because the port compiles against an + /// android.jar that predates the constant being commonly available. + private static final int FLAG_IMMUTABLE = 0x04000000; + + private static final int LABEL_LIGHT = 0xff1c1c1e; + private static final int LABEL_DARK = 0xffffffff; + private static final int SECONDARY_LABEL_LIGHT = 0xff6c6c70; + private static final int SECONDARY_LABEL_DARK = 0xffaeaeb2; + private static final int BACKGROUND_LIGHT = 0xffffffff; + private static final int BACKGROUND_DARK = 0xff1c1c1e; + private static final int ACCENT = 0xff007aff; + + private static final Map resourceIdCache = new HashMap(); + + private CN1SurfaceRenderer() { + } + + /// Renders a descriptor node tree into a `RemoteViews` hierarchy. + /// + /// #### Parameters + /// + /// - `ctx`: an Android context + /// - `node`: the root node of the serialized layout + /// - `state`: the state map whose values resolve `${key}` placeholders, may be null + /// - `source`: the widget kind id or live activity type, delivered with action events + /// - `imagesDir`: the directory holding the published `.png` blobs, may be null + /// + /// #### Returns + /// + /// the rendered RemoteViews, never null + public static RemoteViews render(Context ctx, JSONObject node, JSONObject state, + String source, File imagesDir) { + RenderContext rc = new RenderContext(ctx, state, source, imagesDir); + return renderNode(node, rc, 0); + } + + private static final class RenderContext { + final Context ctx; + final String pkg; + final JSONObject state; + final String source; + final File imagesDir; + final boolean dark; + final float density; + int bitmapBytes; + + RenderContext(Context ctx, JSONObject state, String source, File imagesDir) { + this.ctx = ctx; + this.pkg = ctx.getPackageName(); + this.state = state; + this.source = source; + this.imagesDir = imagesDir; + int night = ctx.getResources().getConfiguration().uiMode + & Configuration.UI_MODE_NIGHT_MASK; + this.dark = night == Configuration.UI_MODE_NIGHT_YES; + this.density = ctx.getResources().getDisplayMetrics().density; + } + + int layout(String name) { + return resolveId(ctx, name, "layout"); + } + + int rootId() { + return resolveId(ctx, "cn1_surface_root", "id"); + } + + int drawable(String name) { + return resolveId(ctx, name, "drawable"); + } + + int dip(int dips) { + return Math.round(dips * density); + } + } + + private static int resolveId(Context ctx, String name, String type) { + String key = ctx.getPackageName() + '/' + type + '/' + name; + synchronized (resourceIdCache) { + Integer cached = resourceIdCache.get(key); + if (cached != null) { + return cached.intValue(); + } + } + int id = ctx.getResources().getIdentifier(name, type, ctx.getPackageName()); + synchronized (resourceIdCache) { + resourceIdCache.put(key, Integer.valueOf(id)); + } + if (id == 0) { + Log.w(TAG, "Missing surface resource " + type + "/" + name + + "; was the app built with surfaces support?"); + } + return id; + } + + private static RemoteViews renderNode(JSONObject node, RenderContext rc, int depth) { + if (node == null || depth > MAX_DEPTH) { + if (depth > MAX_DEPTH) { + Log.w(TAG, "Surface descriptor exceeds the maximum depth of " + MAX_DEPTH + + "; deeper nodes are dropped"); + } + return new RemoteViews(rc.pkg, rc.layout("cn1_surface_spacer")); + } + String type = node.optString("t", ""); + RemoteViews rv; + if ("col".equals(type)) { + rv = renderContainer(node, rc, depth, false); + } else if ("row".equals(type)) { + rv = renderContainer(node, rc, depth, true); + } else if ("box".equals(type)) { + rv = renderBox(node, rc, depth); + } else if ("text".equals(type)) { + rv = renderText(node, rc); + } else if ("dyn".equals(type)) { + rv = renderDynamicText(node, rc); + } else if ("img".equals(type)) { + rv = renderImage(node, rc); + } else if ("prog".equals(type)) { + rv = renderProgress(node, rc); + } else if ("vec".equals(type)) { + rv = renderVector(node, rc); + } else if ("spacer".equals(type)) { + rv = renderSpacer(node, rc); + } else { + Log.w(TAG, "Unknown surface node type '" + type + "'; rendering as empty space"); + rv = new RemoteViews(rc.pkg, rc.layout("cn1_surface_spacer")); + } + applyCommon(rv, node, rc); + return rv; + } + + // --- containers ----------------------------------------------------------- + + private static RemoteViews renderContainer(JSONObject node, RenderContext rc, int depth, + boolean horizontal) { + RemoteViews rv = new RemoteViews(rc.pkg, + rc.layout(horizontal ? "cn1_surface_row" : "cn1_surface_column")); + int rootId = rc.rootId(); + int spacing = node.optInt("spacing", 0); + JSONArray ch = node.optJSONArray("ch"); + if (ch == null) { + return rv; + } + for (int i = 0; i < ch.length(); i++) { + JSONObject child = ch.optJSONObject(i); + if (child == null) { + continue; + } + RemoteViews childRv = renderNode(child, rc, depth + 1); + boolean weighted = child.optInt("weight", 0) >= 1 || isExpandingSpacer(child); + String cellName; + if (horizontal) { + cellName = weighted ? "cn1_surface_cell_weight1_h" : "cn1_surface_cell_h"; + } else { + cellName = weighted ? "cn1_surface_cell_weight1_v" : "cn1_surface_cell_v"; + } + RemoteViews cell = new RemoteViews(rc.pkg, rc.layout(cellName)); + cell.addView(rootId, childRv); + // container spacing becomes leading padding on every cell but the first, and a + // fixed-minimum spacer becomes cell padding along the container axis (padding + // contributes to a wrapped view's size on every API level) + int leadPad = i > 0 && spacing > 0 ? rc.dip(spacing) : 0; + if ("spacer".equals(child.optString("t"))) { + int min = child.optInt("min", 0); + if (min > 0) { + leadPad += rc.dip(min); + } + } + if (leadPad > 0) { + if (horizontal) { + cell.setViewPadding(rootId, leadPad, 0, 0, 0); + } else { + cell.setViewPadding(rootId, 0, leadPad, 0, 0); + } + } + applyFixedSize(cell, child, rc); + rv.addView(rootId, cell); + } + applyChildAlignment(rv, ch, rc); + return rv; + } + + private static RemoteViews renderBox(JSONObject node, RenderContext rc, int depth) { + RemoteViews rv = new RemoteViews(rc.pkg, rc.layout("cn1_surface_box")); + int rootId = rc.rootId(); + JSONArray ch = node.optJSONArray("ch"); + if (ch == null) { + return rv; + } + for (int i = 0; i < ch.length(); i++) { + JSONObject child = ch.optJSONObject(i); + if (child == null) { + continue; + } + // FrameLayout children stack top-leading; per-child gravity is not remotable. + RemoteViews childRv = renderNode(child, rc, depth + 1); + applyFixedSize(childRv, child, rc); + rv.addView(rootId, childRv); + } + return rv; + } + + private static boolean isExpandingSpacer(JSONObject node) { + return "spacer".equals(node.optString("t")) && !node.has("min"); + } + + /// API 31+ approximation: the first child that declares an alignment sets the gravity of + /// the whole LinearLayout container (LinearLayout#setGravity became remotable in S). + private static void applyChildAlignment(RemoteViews rv, JSONArray ch, RenderContext rc) { + if (Build.VERSION.SDK_INT < 31) { + return; + } + for (int i = 0; i < ch.length(); i++) { + JSONObject child = ch.optJSONObject(i); + if (child == null) { + continue; + } + String align = child.optString("align", null); + if (align != null && align.length() > 0) { + rv.setInt(rc.rootId(), "setGravity", gravityFor(align)); + return; + } + } + } + + private static int gravityFor(String align) { + if ("topLeading".equals(align)) { + return Gravity.TOP | Gravity.START; + } + if ("top".equals(align)) { + return Gravity.TOP | Gravity.CENTER_HORIZONTAL; + } + if ("topTrailing".equals(align)) { + return Gravity.TOP | Gravity.END; + } + if ("leading".equals(align)) { + return Gravity.CENTER_VERTICAL | Gravity.START; + } + if ("trailing".equals(align)) { + return Gravity.CENTER_VERTICAL | Gravity.END; + } + if ("bottomLeading".equals(align)) { + return Gravity.BOTTOM | Gravity.START; + } + if ("bottom".equals(align)) { + return Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; + } + if ("bottomTrailing".equals(align)) { + return Gravity.BOTTOM | Gravity.END; + } + return Gravity.CENTER; + } + + // --- leaf nodes ----------------------------------------------------------- + + private static RemoteViews renderText(JSONObject node, RenderContext rc) { + RemoteViews rv = new RemoteViews(rc.pkg, rc.layout("cn1_surface_text")); + int rootId = rc.rootId(); + String text = interpolate(node.optString("text", ""), rc.state); + rv.setTextViewText(rootId, styledText(text, node.optString("fw", null))); + applyTextStyle(rv, node, rc); + int maxLines = node.optInt("maxLines", 0); + if (maxLines > 0) { + rv.setInt(rootId, "setMaxLines", maxLines); + } + return rv; + } + + private static RemoteViews renderDynamicText(JSONObject node, RenderContext rc) { + int rootId = rc.rootId(); + String style = node.optString("style", "timerDown"); + long date = resolveDate(node, rc); + long now = System.currentTimeMillis(); + RemoteViews rv; + if ("timerDown".equals(style) || "timerUp".equals(style)) { + boolean down = "timerDown".equals(style); + if (down && Build.VERSION.SDK_INT < 24) { + // Chronometer count-down needs API 24; render the remaining time statically. + rv = new RemoteViews(rc.pkg, rc.layout("cn1_surface_text")); + rv.setTextViewText(rootId, formatElapsed(Math.max(0, date - now))); + } else { + rv = new RemoteViews(rc.pkg, rc.layout("cn1_surface_chronometer")); + long base; + if (down) { + base = SystemClock.elapsedRealtime() + (date - now); + } else { + base = SystemClock.elapsedRealtime() - Math.max(0, now - date); + } + rv.setChronometer(rootId, base, null, true); + if (down) { + rv.setChronometerCountDown(rootId, true); + } + } + } else if ("time".equals(style)) { + // TextClock always displays the current time; a fixed timestamp's time-of-day is + // not representable natively on Android. + rv = new RemoteViews(rc.pkg, rc.layout("cn1_surface_textclock")); + } else if ("date".equals(style)) { + rv = new RemoteViews(rc.pkg, rc.layout("cn1_surface_text")); + rv.setTextViewText(rootId, DateFormat.getDateInstance().format(new Date(date))); + } else { + // relative: static approximation computed at render time; refreshes on the next + // widget update rather than continuously. + rv = new RemoteViews(rc.pkg, rc.layout("cn1_surface_text")); + rv.setTextViewText(rootId, DateUtils.getRelativeTimeSpanString(date, now, + DateUtils.MINUTE_IN_MILLIS)); + } + applyTextStyle(rv, node, rc); + return rv; + } + + private static RemoteViews renderImage(JSONObject node, RenderContext rc) { + String scale = node.optString("scale", "fit"); + String layoutName; + if ("fill".equals(scale)) { + layoutName = "cn1_surface_image_fill"; + } else if ("center".equals(scale)) { + layoutName = "cn1_surface_image_center"; + } else { + layoutName = "cn1_surface_image"; + } + RemoteViews rv = new RemoteViews(rc.pkg, rc.layout(layoutName)); + int rootId = rc.rootId(); + Bitmap bmp = loadBitmap(node.optString("name", ""), node, rc); + if (bmp != null) { + rv.setImageViewBitmap(rootId, bmp); + } + JSONObject tint = node.optJSONObject("tint"); + if (tint != null) { + rv.setInt(rootId, "setColorFilter", resolveColor(tint, rc, LABEL_LIGHT, LABEL_DARK)); + } + return rv; + } + + private static RemoteViews renderProgress(JSONObject node, RenderContext rc) { + // Determinate circular progress is not renderable in an app widget; the circular + // layout is a pre-baked linear fallback per the LCD contract. + boolean circular = "circular".equals(node.optString("style", "linear")); + RemoteViews rv = new RemoteViews(rc.pkg, + rc.layout(circular ? "cn1_surface_progress_circular" : "cn1_surface_progress")); + int rootId = rc.rootId(); + double fraction = resolveFraction(node, rc); + rv.setProgressBar(rootId, 1000, (int) Math.round(fraction * 1000), false); + JSONObject color = node.optJSONObject("color"); + if (color != null && Build.VERSION.SDK_INT >= 31) { + setColorStateList(rv, rootId, "setProgressTintList", + resolveColor(color, rc, ACCENT, ACCENT)); + } + return rv; + } + + // --- vector nodes ----------------------------------------------------------- + + /// Renders a `vec` node into an in-process bitmap: RemoteViews cannot draw, but this + /// renderer runs inside the app process where Canvas is available, so vector art (clock + /// faces, gauges, dials) rasterizes here and ships as an ImageView bitmap. The bitmap is + /// sized to the node's target dips (fixed `w`/`h` when set, else the view box) times the + /// display density, capped by `MAX_BITMAP_DIMENSION` and the cumulative bitmap budget. + private static RemoteViews renderVector(JSONObject node, RenderContext rc) { + RemoteViews rv = new RemoteViews(rc.pkg, rc.layout("cn1_surface_image")); + Bitmap bmp = renderVectorBitmap(node, rc); + if (bmp != null) { + rv.setImageViewBitmap(rc.rootId(), bmp); + } + return rv; + } + + private static Bitmap renderVectorBitmap(JSONObject node, RenderContext rc) { + double vw = node.optDouble("vw", 0); + double vh = node.optDouble("vh", 0); + JSONArray ops = node.optJSONArray("ops"); + if (vw <= 0 || vh <= 0 || ops == null) { + return null; + } + int wDips = node.optInt("w", 0); + int hDips = node.optInt("h", 0); + if (wDips <= 0) { + wDips = (int) Math.round(vw); + } + if (hDips <= 0) { + hDips = (int) Math.round(vh); + } + int wpx = Math.min(rc.dip(wDips), MAX_BITMAP_DIMENSION); + int hpx = Math.min(rc.dip(hDips), MAX_BITMAP_DIMENSION); + if (wpx <= 0 || hpx <= 0) { + return null; + } + int remaining = BITMAP_BUDGET_BYTES - rc.bitmapBytes; + if (wpx * hpx * 4 > remaining) { + // shrink instead of dropping: a blurry clock beats no clock + double shrink = Math.sqrt(remaining / (wpx * hpx * 4d)); + wpx = (int) (wpx * shrink); + hpx = (int) (hpx * shrink); + if (wpx < 8 || hpx < 8) { + Log.w(TAG, "Skipping vector node: the rendered tree exceeds " + + (BITMAP_BUDGET_BYTES / 1024) + "kb of bitmap data (binder " + + "transactions cap RemoteViews payloads)"); + return null; + } + Log.w(TAG, "Vector node downscaled to " + wpx + "x" + hpx + + " to stay within the RemoteViews bitmap budget"); + } + try { + Bitmap bmp = Bitmap.createBitmap(wpx, hpx, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bmp); + // scale the view box into the bitmap preserving aspect ratio, centered; after this + // all op coordinates, stroke widths and font sizes are in view-box units + float scale = (float) Math.min(wpx / vw, hpx / vh); + canvas.translate((float) (wpx - vw * scale) / 2f, (float) (hpx - vh * scale) / 2f); + canvas.scale(scale, scale); + drawVectorOps(canvas, ops, rc); + rc.bitmapBytes += wpx * hpx * 4; + return bmp; + } catch (Throwable t) { + Log.w(TAG, "Failed to render vector node", t); + return null; + } + } + + private static void drawVectorOps(Canvas canvas, JSONArray ops, RenderContext rc) { + for (int i = 0; i < ops.length(); i++) { + JSONObject op = ops.optJSONObject(i); + if (op == null) { + continue; + } + String o = op.optString("o", ""); + if ("rot".equals(o)) { + canvas.save(); + canvas.rotate(resolveDegrees(op, rc), + (float) op.optDouble("px", 0), (float) op.optDouble("py", 0)); + JSONArray nested = op.optJSONArray("ops"); + if (nested != null) { + drawVectorOps(canvas, nested, rc); + } + canvas.restore(); + continue; + } + Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + JSONObject c = op.optJSONObject("c"); + paint.setColor(c == null ? (rc.dark ? LABEL_DARK : LABEL_LIGHT) + : resolveColor(c, rc, LABEL_LIGHT, LABEL_DARK)); + if ("fillRect".equals(o)) { + float x = (float) op.optDouble("x", 0); + float y = (float) op.optDouble("y", 0); + canvas.drawRect(x, y, x + (float) op.optDouble("w", 0), + y + (float) op.optDouble("h", 0), paint); + } else if ("fillRoundRect".equals(o)) { + float x = (float) op.optDouble("x", 0); + float y = (float) op.optDouble("y", 0); + float corner = (float) op.optDouble("corner", 0); + canvas.drawRoundRect(new RectF(x, y, x + (float) op.optDouble("w", 0), + y + (float) op.optDouble("h", 0)), corner, corner, paint); + } else if ("fillEllipse".equals(o)) { + canvas.drawOval(ellipseRect(op), paint); + } else if ("fillArc".equals(o)) { + // wire angles are clock convention (0 = 12 o'clock, clockwise); Canvas.drawArc + // starts at 3 o'clock advancing clockwise, so shift the start by -90 + canvas.drawArc(ellipseRect(op), (float) op.optDouble("start", 0) - 90f, + (float) op.optDouble("sweep", 0), true, paint); + } else if ("strokeEllipse".equals(o)) { + strokePaint(paint, op, false); + canvas.drawOval(ellipseRect(op), paint); + } else if ("strokeArc".equals(o)) { + strokePaint(paint, op, true); + canvas.drawArc(ellipseRect(op), (float) op.optDouble("start", 0) - 90f, + (float) op.optDouble("sweep", 0), false, paint); + } else if ("line".equals(o)) { + strokePaint(paint, op, true); + canvas.drawLine((float) op.optDouble("x1", 0), (float) op.optDouble("y1", 0), + (float) op.optDouble("x2", 0), (float) op.optDouble("y2", 0), paint); + } else if ("fillPath".equals(o)) { + Path path = vectorPath(op, true); + if (path != null) { + canvas.drawPath(path, paint); + } + } else if ("strokePath".equals(o)) { + Path path = vectorPath(op, op.optBoolean("close", false)); + if (path != null) { + strokePaint(paint, op, true); + canvas.drawPath(path, paint); + } + } else if ("text".equals(o)) { + String text = interpolate(op.optString("text", ""), rc.state); + if (text.length() > 0) { + paint.setTextSize((float) op.optDouble("size", 14)); + paint.setTextAlign(Paint.Align.CENTER); + String fw = op.optString("fw", "regular"); + if ("semibold".equals(fw) || "bold".equals(fw)) { + paint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)); + } + // drawText positions the baseline at y, matching the wire contract + canvas.drawText(text, (float) op.optDouble("x", 0), + (float) op.optDouble("y", 0), paint); + } + } + } + } + + private static float resolveDegrees(JSONObject op, RenderContext rc) { + String degKey = op.optString("degKey", null); + if (degKey != null && degKey.length() > 0 && rc.state != null) { + Object v = rc.state.opt(degKey); + if (v instanceof Number) { + return ((Number) v).floatValue(); + } + return 0f; + } + return (float) op.optDouble("deg", 0); + } + + private static RectF ellipseRect(JSONObject op) { + float cx = (float) op.optDouble("cx", 0); + float cy = (float) op.optDouble("cy", 0); + float rx = (float) op.optDouble("rx", 0); + float ry = (float) op.optDouble("ry", 0); + return new RectF(cx - rx, cy - ry, cx + rx, cy + ry); + } + + private static void strokePaint(Paint paint, JSONObject op, boolean roundCaps) { + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth((float) op.optDouble("sw", 1)); + if (roundCaps) { + paint.setStrokeCap(Paint.Cap.ROUND); + paint.setStrokeJoin(Paint.Join.ROUND); + } + } + + private static Path vectorPath(JSONObject op, boolean close) { + JSONArray pts = op.optJSONArray("pts"); + if (pts == null || pts.length() < 4) { + return null; + } + Path path = new Path(); + path.moveTo((float) pts.optDouble(0, 0), (float) pts.optDouble(1, 0)); + for (int i = 2; i + 1 < pts.length(); i += 2) { + path.lineTo((float) pts.optDouble(i, 0), (float) pts.optDouble(i + 1, 0)); + } + if (close) { + path.close(); + } + return path; + } + + private static RemoteViews renderSpacer(JSONObject node, RenderContext rc) { + // A spacer's behavior is realized by its parent container: without "min" it rides a + // weighted cell (expanding), with "min" the cell gets axis-aligned padding. A spacer + // used outside a column/row (root or box child) has no effect on Android. + return new RemoteViews(rc.pkg, rc.layout("cn1_surface_spacer")); + } + + // --- shared styling ------------------------------------------------------- + + private static void applyCommon(RemoteViews rv, JSONObject node, RenderContext rc) { + int rootId = rc.rootId(); + JSONArray pad = node.optJSONArray("pad"); + if (pad != null && pad.length() == 4) { + // wire order: [top, right, bottom, left] + rv.setViewPadding(rootId, rc.dip(pad.optInt(3)), rc.dip(pad.optInt(0)), + rc.dip(pad.optInt(1)), rc.dip(pad.optInt(2))); + } + JSONObject bg = node.optJSONObject("bg"); + if (bg != null) { + int color = resolveColor(bg, rc, BACKGROUND_LIGHT, BACKGROUND_DARK); + int corner = node.optInt("corner", 0); + if (corner > 0 && Build.VERSION.SDK_INT >= 31) { + // The rounded drawable has a fixed 12dp radius; the node radius selects + // rounded-vs-square only. + rv.setInt(rootId, "setBackgroundResource", rc.drawable("cn1_surface_rounded")); + setColorStateList(rv, rootId, "setBackgroundTintList", color); + } else { + // Below API 31 backgroundTintList is not remotable, so corners render square. + rv.setInt(rootId, "setBackgroundColor", color); + } + } + JSONObject action = node.optJSONObject("action"); + if (action != null) { + applyAction(rv, action, rc); + } + } + + private static void applyTextStyle(RemoteViews rv, JSONObject node, RenderContext rc) { + int rootId = rc.rootId(); + int size = node.optInt("size", 0); + if (size > 0) { + rv.setTextViewTextSize(rootId, TypedValue.COMPLEX_UNIT_DIP, size); + } + JSONObject color = node.optJSONObject("color"); + if (color != null) { + rv.setTextColor(rootId, resolveColor(color, rc, LABEL_LIGHT, LABEL_DARK)); + } else { + rv.setTextColor(rootId, rc.dark ? LABEL_DARK : LABEL_LIGHT); + } + } + + private static CharSequence styledText(String text, String fw) { + if ("semibold".equals(fw) || "bold".equals(fw)) { + SpannableString s = new SpannableString(text); + s.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), 0, s.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return s; + } + // light/regular/medium render as regular per the LCD contract + return text; + } + + private static void applyFixedSize(RemoteViews rv, JSONObject node, RenderContext rc) { + int w = node.optInt("w", 0); + int h = node.optInt("h", 0); + if ((w <= 0 && h <= 0) || Build.VERSION.SDK_INT < 31) { + // setViewLayoutWidth/Height exist from API 31 only; fixed sizes are ignored below + // (images still honor them through bitmap scaling in loadBitmap). + return; + } + try { + Method m = RemoteViews.class.getMethod("setViewLayoutWidth", + int.class, float.class, int.class); + if (w > 0) { + m.invoke(rv, Integer.valueOf(rc.rootId()), Float.valueOf(w), + Integer.valueOf(TypedValue.COMPLEX_UNIT_DIP)); + } + if (h > 0) { + Method mh = RemoteViews.class.getMethod("setViewLayoutHeight", + int.class, float.class, int.class); + mh.invoke(rv, Integer.valueOf(rc.rootId()), Float.valueOf(h), + Integer.valueOf(TypedValue.COMPLEX_UNIT_DIP)); + } + } catch (Throwable t) { + Log.w(TAG, "Failed to apply fixed size", t); + } + } + + private static void applyAction(RemoteViews rv, JSONObject action, RenderContext rc) { + String actionId = action.optString("id", ""); + JSONObject params = action.optJSONObject("p"); + String paramsJson = params == null ? null : params.toString(); + Intent intent = new Intent(rc.ctx, CN1SurfaceActionActivity.class); + intent.putExtra(CN1SurfaceActionActivity.EXTRA_SOURCE, rc.source); + intent.putExtra(CN1SurfaceActionActivity.EXTRA_ACTION_ID, actionId); + if (paramsJson != null) { + intent.putExtra(CN1SurfaceActionActivity.EXTRA_ACTION_PARAMS, paramsJson); + } + // The canonical deep-link form doubles as a uniqueness key so PendingIntents with + // different extras never collide. + StringBuilder uri = new StringBuilder("cn1surface://a?src="); + uri.append(Uri.encode(rc.source == null ? "" : rc.source)); + uri.append("&id=").append(Uri.encode(actionId)); + if (paramsJson != null) { + uri.append("&p=").append(Uri.encode(paramsJson)); + } + intent.setData(Uri.parse(uri.toString())); + int requestCode = uri.toString().hashCode(); + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= 23) { + flags |= FLAG_IMMUTABLE; + } + PendingIntent pi = PendingIntent.getActivity(rc.ctx, requestCode, intent, flags); + rv.setOnClickPendingIntent(rc.rootId(), pi); + } + + /// RemoteViews#setColorStateList exists from API 31 only and the port compiles against an + /// older android.jar, so the call goes through reflection (an SDK method name, safe under + /// obfuscation). + private static void setColorStateList(RemoteViews rv, int viewId, String method, int color) { + try { + Method m = RemoteViews.class.getMethod("setColorStateList", + int.class, String.class, android.content.res.ColorStateList.class); + m.invoke(rv, Integer.valueOf(viewId), method, + android.content.res.ColorStateList.valueOf(color)); + } catch (Throwable t) { + Log.w(TAG, "Failed to apply color state list", t); + } + } + + // --- value resolution ----------------------------------------------------- + + private static int resolveColor(JSONObject color, RenderContext rc, int fallbackLight, + int fallbackDark) { + String role = color.optString("role", null); + if (role != null && role.length() > 0) { + if ("label".equals(role)) { + return rc.dark ? LABEL_DARK : LABEL_LIGHT; + } + if ("secondaryLabel".equals(role)) { + return rc.dark ? SECONDARY_LABEL_DARK : SECONDARY_LABEL_LIGHT; + } + if ("background".equals(role)) { + return rc.dark ? BACKGROUND_DARK : BACKGROUND_LIGHT; + } + if ("accent".equals(role)) { + return ACCENT; + } + return rc.dark ? fallbackDark : fallbackLight; + } + if (color.has("l") || color.has("d")) { + long l = color.optLong("l", fallbackLight); + long d = color.optLong("d", l); + return (int) (rc.dark ? d : l); + } + return rc.dark ? fallbackDark : fallbackLight; + } + + private static long resolveDate(JSONObject node, RenderContext rc) { + String dateKey = node.optString("dateKey", null); + if (dateKey != null && dateKey.length() > 0 && rc.state != null) { + Object v = rc.state.opt(dateKey); + if (v instanceof Number) { + return ((Number) v).longValue(); + } + if (v instanceof String) { + try { + return Long.parseLong((String) v); + } catch (NumberFormatException ignore) { + } + } + } + return node.optLong("date", System.currentTimeMillis()); + } + + private static double resolveFraction(JSONObject node, RenderContext rc) { + double fraction; + String valueKey = node.optString("valueKey", null); + if (valueKey != null && valueKey.length() > 0 && rc.state != null + && rc.state.opt(valueKey) instanceof Number) { + fraction = ((Number) rc.state.opt(valueKey)).doubleValue(); + } else if (node.has("start") && node.has("end")) { + // Date-interval progress freezes at render time on Android; the next widget + // update recomputes it. + long start = node.optLong("start"); + long end = node.optLong("end"); + long now = System.currentTimeMillis(); + fraction = end <= start ? 1d : (now - start) / (double) (end - start); + } else { + fraction = node.optDouble("value", 0d); + } + return Math.max(0d, Math.min(1d, fraction)); + } + + private static Bitmap loadBitmap(String name, JSONObject node, RenderContext rc) { + if (name == null || name.length() == 0 || rc.imagesDir == null) { + return null; + } + // published names are content-hash based ("img"); reject anything path-like + if (!name.matches("[a-zA-Z0-9_-]+")) { + Log.w(TAG, "Ignoring surface image with suspicious name '" + name + "'"); + return null; + } + File f = new File(rc.imagesDir, name + ".png"); + if (!f.exists()) { + Log.w(TAG, "Surface image " + f + " was not published"); + return null; + } + try { + BitmapFactory.Options bounds = new BitmapFactory.Options(); + bounds.inJustDecodeBounds = true; + BitmapFactory.decodeFile(f.getAbsolutePath(), bounds); + int maxDim = Math.max(bounds.outWidth, bounds.outHeight); + BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inSampleSize = 1; + while (maxDim / opts.inSampleSize > MAX_BITMAP_DIMENSION) { + opts.inSampleSize *= 2; + } + Bitmap bmp = BitmapFactory.decodeFile(f.getAbsolutePath(), opts); + if (bmp == null) { + return null; + } + int w = node.optInt("w", 0); + int h = node.optInt("h", 0); + if (w > 0 || h > 0) { + bmp = scaleToFixedSize(bmp, w, h, "fill".equals(node.optString("scale")), rc); + } + int bytes = bmp.getWidth() * bmp.getHeight() * 4; + if (rc.bitmapBytes + bytes > BITMAP_BUDGET_BYTES) { + Log.w(TAG, "Skipping surface image '" + name + "': the rendered tree exceeds " + + (BITMAP_BUDGET_BYTES / 1024) + "kb of bitmap data (binder " + + "transactions cap RemoteViews payloads)"); + return null; + } + rc.bitmapBytes += bytes; + return bmp; + } catch (Throwable t) { + Log.w(TAG, "Failed to decode surface image " + f, t); + return null; + } + } + + private static Bitmap scaleToFixedSize(Bitmap bmp, int wDips, int hDips, boolean fill, + RenderContext rc) { + int targetW = wDips > 0 ? rc.dip(wDips) : 0; + int targetH = hDips > 0 ? rc.dip(hDips) : 0; + if (targetW <= 0) { + targetW = Math.round(bmp.getWidth() * (targetH / (float) bmp.getHeight())); + } + if (targetH <= 0) { + targetH = Math.round(bmp.getHeight() * (targetW / (float) bmp.getWidth())); + } + targetW = Math.min(targetW, MAX_BITMAP_DIMENSION); + targetH = Math.min(targetH, MAX_BITMAP_DIMENSION); + if (targetW <= 0 || targetH <= 0) { + return bmp; + } + try { + if (fill) { + // center-crop to the requested aspect before scaling + float scale = Math.max(targetW / (float) bmp.getWidth(), + targetH / (float) bmp.getHeight()); + int cropW = Math.min(bmp.getWidth(), Math.round(targetW / scale)); + int cropH = Math.min(bmp.getHeight(), Math.round(targetH / scale)); + int x = (bmp.getWidth() - cropW) / 2; + int y = (bmp.getHeight() - cropH) / 2; + bmp = Bitmap.createBitmap(bmp, x, y, cropW, cropH); + } + return Bitmap.createScaledBitmap(bmp, targetW, targetH, true); + } catch (Throwable t) { + Log.w(TAG, "Failed to scale surface image", t); + return bmp; + } + } + + private static String formatElapsed(long millis) { + long totalSeconds = millis / 1000; + long hours = totalSeconds / 3600; + long minutes = (totalSeconds % 3600) / 60; + long seconds = totalSeconds % 60; + StringBuilder sb = new StringBuilder(); + if (hours > 0) { + sb.append(hours).append(':'); + if (minutes < 10) { + sb.append('0'); + } + } + sb.append(minutes).append(':'); + if (seconds < 10) { + sb.append('0'); + } + sb.append(seconds); + return sb.toString(); + } + + /// Resolves `${key}` placeholders from the state map. Unknown keys resolve to an empty + /// string so stale layouts degrade gracefully. + static String interpolate(String text, JSONObject state) { + if (text == null || text.indexOf("${") < 0) { + return text; + } + StringBuilder sb = new StringBuilder(text.length()); + int i = 0; + int n = text.length(); + while (i < n) { + int start = text.indexOf("${", i); + if (start < 0) { + sb.append(text, i, n); + break; + } + int end = text.indexOf('}', start + 2); + if (end < 0) { + sb.append(text, i, n); + break; + } + sb.append(text, i, start); + String key = text.substring(start + 2, end); + Object v = state == null ? null : state.opt(key); + if (v != null && v != JSONObject.NULL) { + sb.append(String.valueOf(v)); + } + i = end + 1; + } + return sb.toString(); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java new file mode 100644 index 00000000000..20b5e41b23a --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android.surfaces; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// Persistence for published surfaces on Android. Surfaces render while the app process may be +/// dead (widget updates run in short-lived broadcast receivers), so everything published through +/// the bridge is written under `filesDir/cn1surfaces/`: +/// +/// - `cn1surfaces//timeline.json` plus the `.png` blobs of a widget kind +/// - `cn1surfaces/activities/.json` (descriptor merged with the latest state) plus +/// `cn1surfaces/activities//.png` blobs of a live activity +/// +/// Writes are atomic (write to a temp file, then rename) because the widget provider may read +/// concurrently with a publish. A `SharedPreferences` file named `cn1surfaces` tracks the +/// registered kind ids (so `reloadWidgets(null)` can iterate them) and the live activity id +/// sequence. All helpers are static; there is no instance state. +public final class CN1SurfaceStore { + private static final String TAG = "CN1Surfaces"; + private static final String PREFS = "cn1surfaces"; + private static final String KEY_KINDS = "kinds"; + private static final String KEY_ACTIVITY_SEQ = "laSeq"; + private static final String KEY_FETCH_CLASS = "bgFetchClass"; + private static final String KEY_FETCH_AT_PREFIX = "bgFetchAt_"; + + private CN1SurfaceStore() { + } + + // --- widget timelines ----------------------------------------------------- + + /// Returns the storage directory of a widget kind (also the image blob directory handed to + /// the renderer). The directory is not created by this call. + public static File kindDir(Context ctx, String kindId) { + return new File(baseDir(ctx), sanitize(kindId)); + } + + /// Atomically replaces the persisted timeline of a widget kind and garbage collects image + /// blobs the replacement timeline no longer references (the document's `images` list is the + /// complete reference set; content-hash names would otherwise accumulate without bound). + public static void writeWidgetTimeline(Context ctx, String kindId, String timelineJson, + Map images) throws IOException { + File dir = kindDir(ctx, kindId); + mkdirs(dir); + writeImages(dir, images); + writeAtomic(new File(dir, "timeline.json"), utf8(timelineJson)); + deleteUnreferencedImages(dir, timelineJson); + } + + private static void deleteUnreferencedImages(File dir, String timelineJson) { + try { + org.json.JSONObject doc = new org.json.JSONObject(timelineJson); + org.json.JSONArray names = doc.optJSONArray("images"); + java.util.HashSet referenced = new java.util.HashSet(); + if (names != null) { + for (int i = 0; i < names.length(); i++) { + referenced.add(sanitize(names.optString(i))); + } + } + File[] files = dir.listFiles(); + if (files == null) { + return; + } + for (File f : files) { + String name = f.getName(); + if (name.endsWith(".png") + && !referenced.contains(name.substring(0, name.length() - 4))) { + delete(f); + } + } + } catch (Throwable t) { + Log.w(TAG, "Failed to garbage collect widget images in " + dir, t); + } + } + + /// Returns the persisted timeline JSON of a widget kind, or null when nothing was published. + public static String readWidgetTimeline(Context ctx, String kindId) { + return readText(new File(kindDir(ctx, kindId), "timeline.json")); + } + + // --- live activities ------------------------------------------------------ + + /// Allocates the next live activity id ("la1", "la2", ...). + public static String newActivityId(Context ctx) { + SharedPreferences prefs = prefs(ctx); + int seq = prefs.getInt(KEY_ACTIVITY_SEQ, 0) + 1; + prefs.edit().putInt(KEY_ACTIVITY_SEQ, seq).apply(); + return "la" + seq; + } + + /// Atomically persists a live activity descriptor (with its state already merged in). + public static void writeLiveActivity(Context ctx, String activityId, String descriptorJson, + Map images) throws IOException { + File dir = activitiesDir(ctx); + mkdirs(dir); + if (images != null && !images.isEmpty()) { + File imgDir = liveActivityImagesDir(ctx, activityId); + mkdirs(imgDir); + writeImages(imgDir, images); + } + writeAtomic(new File(dir, sanitize(activityId) + ".json"), utf8(descriptorJson)); + } + + /// Returns the persisted descriptor of a live activity, or null when unknown. + public static String readLiveActivity(Context ctx, String activityId) { + return readText(new File(activitiesDir(ctx), sanitize(activityId) + ".json")); + } + + /// Returns the image blob directory of a live activity. + public static File liveActivityImagesDir(Context ctx, String activityId) { + return new File(activitiesDir(ctx), sanitize(activityId)); + } + + /// Removes a live activity's descriptor and image blobs. + public static void deleteLiveActivity(Context ctx, String activityId) { + delete(new File(activitiesDir(ctx), sanitize(activityId) + ".json")); + File imgDir = liveActivityImagesDir(ctx, activityId); + File[] blobs = imgDir.listFiles(); + if (blobs != null) { + for (File blob : blobs) { + delete(blob); + } + } + delete(imgDir); + } + + // --- kind bookkeeping ----------------------------------------------------- + + /// Records a widget kind id so `reloadWidgets(null)` can iterate every published kind. + public static void rememberKind(Context ctx, String kindId) { + SharedPreferences prefs = prefs(ctx); + String joined = prefs.getString(KEY_KINDS, ""); + for (String existing : joined.split(",")) { + if (existing.equals(kindId)) { + return; + } + } + String updated = joined.length() == 0 ? kindId : joined + "," + kindId; + prefs.edit().putString(KEY_KINDS, updated).apply(); + } + + /// Returns the recorded widget kind ids, possibly empty. + public static List getRememberedKinds(Context ctx) { + List out = new ArrayList(); + String joined = prefs(ctx).getString(KEY_KINDS, ""); + for (String kind : joined.split(",")) { + if (kind.length() > 0) { + out.add(kind); + } + } + return out; + } + + // --- widget-driven refresh ------------------------------------------------ + + /// Records the class name of the app's `com.codename1.background.BackgroundFetch` listener + /// so a widget rendering an exhausted timeline can start the fetch service while the app + /// process is dead. Called by the bridge on every publish; a null name (the app declares + /// no background fetch) is a no-op, keeping this zero-cost for apps without one. + public static void rememberBackgroundFetchClass(Context ctx, String className) { + if (className == null || className.length() == 0) { + return; + } + SharedPreferences prefs = prefs(ctx); + if (!className.equals(prefs.getString(KEY_FETCH_CLASS, null))) { + prefs.edit().putString(KEY_FETCH_CLASS, className).apply(); + } + } + + /// Returns the recorded `BackgroundFetch` listener class name, or null when the app never + /// published while declaring background fetch. + public static String getBackgroundFetchClass(Context ctx) { + String name = prefs(ctx).getString(KEY_FETCH_CLASS, ""); + return name.length() == 0 ? null : name; + } + + /// Claims a widget-driven background fetch slot for a kind: returns true (recording the + /// attempt time) when no attempt happened within the throttle window, false to skip. A + /// recorded time in the future (the clock jumped backwards) resets the window instead of + /// blocking fetches until the clock catches up. + public static boolean tryClaimBackgroundFetch(Context ctx, String kindId, long now, + long throttleMillis) { + SharedPreferences prefs = prefs(ctx); + String key = KEY_FETCH_AT_PREFIX + sanitize(kindId); + long last = prefs.getLong(key, 0); + if (last <= now && now - last < throttleMillis) { + return false; + } + prefs.edit().putLong(key, now).apply(); + return true; + } + + // --- internals ------------------------------------------------------------ + + private static File baseDir(Context ctx) { + return new File(ctx.getFilesDir(), "cn1surfaces"); + } + + private static File activitiesDir(Context ctx) { + return new File(baseDir(ctx), "activities"); + } + + private static SharedPreferences prefs(Context ctx) { + return ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + private static void writeImages(File dir, Map images) throws IOException { + if (images == null) { + return; + } + for (Map.Entry e : images.entrySet()) { + String name = sanitize(e.getKey()); + if (e.getValue() != null) { + writeAtomic(new File(dir, name + ".png"), e.getValue()); + } + } + } + + private static void writeAtomic(File target, byte[] data) throws IOException { + File tmp = new File(target.getParentFile(), target.getName() + ".tmp"); + OutputStream os = new FileOutputStream(tmp); + try { + os.write(data); + } finally { + os.close(); + } + if (!tmp.renameTo(target)) { + // rename over an existing file is atomic on the filesystems Android uses, but be + // defensive against exotic mounts + delete(target); + if (!tmp.renameTo(target)) { + throw new IOException("Failed to move " + tmp + " to " + target); + } + } + } + + private static String readText(File f) { + if (!f.exists()) { + return null; + } + InputStream in = null; + try { + in = new FileInputStream(f); + java.io.ByteArrayOutputStream bo = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int r; + while ((r = in.read(buf)) > 0) { + bo.write(buf, 0, r); + } + return new String(bo.toByteArray(), "UTF-8"); + } catch (IOException ex) { + Log.w(TAG, "Failed to read " + f, ex); + return null; + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException ignore) { + } + } + } + } + + private static byte[] utf8(String s) { + try { + return s.getBytes("UTF-8"); + } catch (UnsupportedEncodingException ex) { + // UTF-8 is guaranteed on Android; this cannot happen + throw new IllegalStateException("UTF-8 unsupported", ex); + } + } + + private static void mkdirs(File dir) throws IOException { + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("Failed to create " + dir); + } + } + + private static void delete(File f) { + if (f.exists() && !f.delete()) { + Log.w(TAG, "Failed to delete " + f); + } + } + + private static String sanitize(String name) { + StringBuilder sb = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '_' || c == '-') { + sb.append(c); + } else { + sb.append('_'); + } + } + return sb.toString(); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java new file mode 100644 index 00000000000..c107c7392ad --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1WidgetProvider.java @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android.surfaces; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import android.widget.RemoteViews; + +import org.json.JSONArray; +import org.json.JSONObject; + +/// The generic widget provider behind every Codename One widget kind. The build generates one +/// tiny subclass per kind declared in `surfaces.json` (class-name convention +/// `com.codename1.impl.android.CN1Widget_`) whose only job is returning the kind id; +/// everything else -- reading the persisted timeline, picking the active entry, choosing a size +/// bucket, rendering, and scheduling entry flips -- lives here. +/// +/// Entry flips are scheduled with an *inexact* `AlarmManager.setWindow` (30 second window): no +/// `SCHEDULE_EXACT_ALARM` permission is required and second-precision countdowns are covered by +/// the natively ticking `Chronometer`, not by re-renders. Apps that need to-the-second entry +/// flips can opt in with the `android.surfaces.exactAlarms=true` build hint (default `false`): +/// the build then declares `SCHEDULE_EXACT_ALARM` and records the choice in the +/// `com.codename1.surfaces.EXACT_ALARMS` application meta-data entry this provider reads. With +/// the hint on, flips use `setExactAndAllowWhileIdle` -- on Android 12+ (API 31) only while +/// `AlarmManager.canScheduleExactAlarms()` reports the special app access is still granted, +/// silently falling back to the inexact window when the user revokes it; below API 31 exact +/// alarms need no special access. When an `atEnd` timeline is exhausted +/// (or nothing was published yet) the last known content stays on screen while the widget pulls +/// the app: if the app declares `com.codename1.background.BackgroundFetch` its fetch service is +/// started -- throttled to once per 15 minutes per kind -- so it can fetch data and re-publish +/// without any UI running. Dark-mode colors resolve at render time, so a light/dark switch shows +/// up on the next update rather than instantly. +public abstract class CN1WidgetProvider extends AppWidgetProvider { + /// Broadcast action used for self-scheduled timeline entry flips. + public static final String ACTION_NEXT_ENTRY = "com.codename1.surfaces.NEXT_ENTRY"; + private static final String TAG = "CN1Surfaces"; + private static final int FLAG_IMMUTABLE = 0x04000000; + private static final long FLIP_WINDOW_MILLIS = 30000; + private static final long FETCH_THROTTLE_MILLIS = 15L * 60 * 1000; + + /// Returns the widget kind id this provider renders; implemented by the generated + /// per-kind subclass. + protected abstract String getKindId(); + + @Override + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { + renderAll(context, appWidgetManager, appWidgetIds); + } + + @Override + public void onReceive(Context context, Intent intent) { + if (intent != null && ACTION_NEXT_ENTRY.equals(intent.getAction())) { + AppWidgetManager mgr = AppWidgetManager.getInstance(context); + renderAll(context, mgr, mgr.getAppWidgetIds(new ComponentName(context, getClass()))); + return; + } + super.onReceive(context, intent); + } + + @Override + public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, + int appWidgetId, Bundle newOptions) { + // re-render for the new size bucket + renderAll(context, appWidgetManager, new int[]{appWidgetId}); + } + + private void renderAll(Context context, AppWidgetManager mgr, int[] appWidgetIds) { + if (appWidgetIds == null || appWidgetIds.length == 0) { + return; + } + String kindId = getKindId(); + String json = CN1SurfaceStore.readWidgetTimeline(context, kindId); + if (json == null) { + // nothing published yet; keep the initial placeholder layout but ask the app + // (when it declares background fetch) to produce content + requestAppRefresh(context, kindId); + return; + } + try { + JSONObject doc = new JSONObject(json); + JSONObject layouts = doc.optJSONObject("layouts"); + JSONArray entries = doc.optJSONArray("entries"); + if (layouts == null || layouts.length() == 0) { + return; + } + long now = System.currentTimeMillis(); + JSONObject active = pickActiveEntry(entries, now); + JSONObject state = active == null ? null : active.optJSONObject("state"); + java.io.File imagesDir = CN1SurfaceStore.kindDir(context, kindId); + for (int appWidgetId : appWidgetIds) { + JSONObject layout = pickLayout(layouts, mgr, appWidgetId); + if (layout == null) { + continue; + } + RemoteViews rv = CN1SurfaceRenderer.render(context, layout, state, kindId, + imagesDir); + mgr.updateAppWidget(appWidgetId, rv); + } + long nextFlip = nextFlipDate(entries, now); + scheduleNextFlip(context, nextFlip); + if (nextFlip == 0 && "atEnd".equals(doc.optString("reload", "atEnd"))) { + // reload=atEnd and the timeline is exhausted: the last entry stays on + // screen while the app is asked (throttled) to republish fresh content + requestAppRefresh(context, kindId); + } + } catch (Throwable t) { + Log.w(TAG, "Failed to render widget kind " + kindId, t); + } + } + + /// Widget-driven refresh, the counterpart of the app-driven publish: starts the app's + /// background fetch service so `com.codename1.background.BackgroundFetch` can fetch data + /// and re-publish while no UI (and possibly no app process) exists. Only fires when the + /// app actually declares background fetch -- the bridge records the listener class on + /// publish -- and at most once per 15 minutes per kind. Failures are swallowed: modern + /// Android may refuse a background service start, in which case the widget simply keeps + /// showing the last entry until the app's own fetch schedule catches up. + private static void requestAppRefresh(Context context, String kindId) { + try { + String listenerClass = CN1SurfaceStore.getBackgroundFetchClass(context); + if (listenerClass == null) { + return; + } + if (!CN1SurfaceStore.tryClaimBackgroundFetch(context, kindId, + System.currentTimeMillis(), FETCH_THROTTLE_MILLIS)) { + return; + } + Intent intent = new Intent(context, + com.codename1.impl.android.BackgroundFetchHandler.class); + // same wire format as the alarm-driven fetch path: the listener class rides in + // the data URI (an old putExtra bug workaround the handler still expects) + intent.setData(android.net.Uri.parse("http://codenameone.com/a?" + listenerClass)); + // legal here: a broadcast receiver executing onReceive counts as foreground, + // so the service start is exempt from background execution limits + context.startService(intent); + } catch (Throwable t) { + Log.w(TAG, "Failed to request a background refresh for widget kind " + kindId, t); + } + } + + /// Returns the latest entry whose date has passed, or the first entry when none has. + private static JSONObject pickActiveEntry(JSONArray entries, long now) { + if (entries == null || entries.length() == 0) { + return null; + } + JSONObject active = entries.optJSONObject(0); + for (int i = 0; i < entries.length(); i++) { + JSONObject e = entries.optJSONObject(i); + if (e != null && e.optLong("date") <= now) { + active = e; + } + } + return active; + } + + private static long nextFlipDate(JSONArray entries, long now) { + long next = 0; + if (entries != null) { + for (int i = 0; i < entries.length(); i++) { + JSONObject e = entries.optJSONObject(i); + if (e == null) { + continue; + } + long date = e.optLong("date"); + if (date > now && (next == 0 || date < next)) { + next = date; + } + } + } + return next; + } + + private static JSONObject pickLayout(JSONObject layouts, AppWidgetManager mgr, + int appWidgetId) { + String bucket = "medium"; + try { + Bundle options = mgr.getAppWidgetOptions(appWidgetId); + if (options != null) { + int minW = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0); + int minH = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0); + if (minW >= 250 && minH >= 250) { + bucket = "large"; + } else if (minW > 0 && minW < 250) { + bucket = "small"; + } + } + } catch (Throwable ignore) { + } + JSONObject layout = layouts.optJSONObject(bucket); + if (layout == null) { + layout = layouts.optJSONObject("default"); + } + if (layout == null) { + String[] fallbacks = {"medium", "small", "large", "lockscreen"}; + for (String fallback : fallbacks) { + layout = layouts.optJSONObject(fallback); + if (layout != null) { + break; + } + } + } + return layout; + } + + private void scheduleNextFlip(Context context, long next) { + if (next <= 0) { + return; + } + try { + AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (am == null) { + return; + } + Intent intent = new Intent(context, getClass()); + intent.setAction(ACTION_NEXT_ENTRY); + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= 23) { + flags |= FLAG_IMMUTABLE; + } + PendingIntent pi = PendingIntent.getBroadcast(context, getKindId().hashCode(), + intent, flags); + if (exactAlarmsRequested(context) && canScheduleExactAlarms(am)) { + if (Build.VERSION.SDK_INT >= 23) { + am.setExactAndAllowWhileIdle(AlarmManager.RTC, next, pi); + } else if (Build.VERSION.SDK_INT >= 19) { + am.setExact(AlarmManager.RTC, next, pi); + } else { + am.set(AlarmManager.RTC, next, pi); + } + } else if (Build.VERSION.SDK_INT >= 19) { + am.setWindow(AlarmManager.RTC, next, FLIP_WINDOW_MILLIS, pi); + } else { + am.set(AlarmManager.RTC, next, pi); + } + } catch (Throwable t) { + Log.w(TAG, "Failed to schedule the next timeline entry flip", t); + } + } + + /// True when the build injected the `com.codename1.surfaces.EXACT_ALARMS` application + /// meta-data entry, i.e. the app opted in with the `android.surfaces.exactAlarms` build + /// hint. Any failure reads as "not requested" so the inexact default keeps working. + private static boolean exactAlarmsRequested(Context context) { + try { + android.content.pm.ApplicationInfo ai = context.getPackageManager() + .getApplicationInfo(context.getPackageName(), + android.content.pm.PackageManager.GET_META_DATA); + return ai != null && ai.metaData != null + && ai.metaData.getBoolean("com.codename1.surfaces.EXACT_ALARMS", false); + } catch (Throwable t) { + return false; + } + } + + /// On Android 12+ (API 31) `SCHEDULE_EXACT_ALARM` is special app access the user can + /// revoke, so `AlarmManager.canScheduleExactAlarms()` gates every exact schedule; the + /// method is invoked reflectively because the port compiles against an older SDK. Below + /// API 31 exact alarms need no special access. + private static boolean canScheduleExactAlarms(AlarmManager am) { + if (Build.VERSION.SDK_INT < 31) { + return true; + } + try { + Object can = am.getClass().getMethod("canScheduleExactAlarms").invoke(am); + return Boolean.TRUE.equals(can); + } catch (Throwable t) { + return false; + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index cd310f49673..375b3320207 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -370,6 +370,23 @@ void disconnectSimulatedCar() { } } + /// 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. + @Override + public com.codename1.surfaces.spi.SurfaceBridge getSurfaceBridge() { + if (surfaceBridge == null) { + File surfacesHome = new File(System.getProperty("user.home") + + File.separator + getAppHomeDir(), "cn1surfaces"); + surfaceBridge = new JavaSEWidgetBridge(surfacesHome, isSimulator()); + if (!isSimulator()) { + widgetWindows = new JavaSEWidgetWindows(surfaceBridge, window); + surfaceBridge.setDesktopWindows(widgetWindows); + } + } + return surfaceBridge; + } + private void fireDesktopWindowEvent(com.codename1.ui.events.WindowEvent.Type type) { if (!isDesktop() || !Display.isInitialized()) { return; @@ -927,6 +944,12 @@ private static boolean computeUseAppFrame() { // Simulated in-car (CarPlay / Android Auto) head unit, created from the Car menu. Lets developers // see and click through their com.codename1.car experience locally without a real head unit. private JavaSECarBridge carBridge; + // External surfaces (com.codename1.surfaces): feeds the simulator Widgets preview window in + // 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; + // Desktop floating widget windows manager, created beside the bridge in desktop mode only. + private JavaSEWidgetWindows widgetWindows; // Application frame used for simulator private AppFrame appFrame; private long lastIdleTime; @@ -1168,6 +1191,11 @@ public void actionPerformed(ActionEvent e) { }); sysTray.add(tray); desktopNotificationTray = tray; + if (widgetWindows != null) { + // desktop widgets piggyback on the persistent tray icon: an + // "Add widget: ..." item per registered kind + widgetWindows.installTrayMenu(tray); + } } lastDesktopNotificationId = notif.getId(); desktopNotificationTray.displayMessage(notif.getAlertTitle(), notif.getAlertBody(), @@ -5168,6 +5196,34 @@ public void actionPerformed(ActionEvent e) { return carMenu; } + /// 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. + private JMenu buildWidgetsMenu() { + JMenu widgetsMenu = new JMenu("Widgets"); + registerMenuWithBlit(widgetsMenu); + JMenuItem preview = new JMenuItem("Widgets Preview"); + preview.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + SimulatorWidgets.showWindow((JavaSEWidgetBridge) getSurfaceBridge(), window); + } + }); + widgetsMenu.add(preview); + if (Boolean.getBoolean("cn1.surfaces.autodemo")) { + // Demo/automation hook: -Dcn1.surfaces.autodemo=true opens the Widgets preview + // window on startup so scripted runs (screenshots, samples CI) can observe + // published surfaces without interacting with the menu. + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + SimulatorWidgets.showWindow((JavaSEWidgetBridge) getSurfaceBridge(), window); + } + }); + } + return widgetsMenu; + } + private static Component findStatusBarComponent(Form f) { if (f == null || f.getToolbar() == null) { return null; @@ -6626,6 +6682,7 @@ public void actionPerformed(ActionEvent e) { bar.add(extensionMenu); } bar.add(buildCarMenu()); + bar.add(buildWidgetsMenu()); bar.add(helpMenu); } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWidgetBridge.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWidgetBridge.java new file mode 100644 index 00000000000..e9998f8a533 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWidgetBridge.java @@ -0,0 +1,653 @@ +/* + * 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.io.JSONParser; +import com.codename1.io.Log; +import com.codename1.surfaces.SurfaceRasterizer; +import com.codename1.surfaces.spi.SurfaceBridge; +import com.codename1.ui.Display; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * The JavaSE {@link SurfaceBridge}: the desktop face of the {@code com.codename1.surfaces} + * framework. In simulator mode published widget timelines feed the Widgets preview window + * ({@link SimulatorWidgets}, opened from the simulator's Widgets menu); in desktop mode they feed + * frameless floating widget windows ({@link JavaSEWidgetWindows}) that persist across runs. + * + *

Everything published through this bridge is kept in memory for the UI and persisted + * to disk (under {@code /cn1surfaces//} -- the same home directory the port + * uses for storage) so desktop floating widgets can restore on the next launch, honoring the + * dead-process rule of the surfaces SPI. Live activities are transient and are not persisted.

+ * + *

Action clicks decoded by the surface windows are routed through + * {@code com.codename1.surfaces.Surfaces.dispatchAction(...)}, which handles EDT marshaling and + * cold-start queueing itself.

+ */ +public class JavaSEWidgetBridge implements SurfaceBridge { + + /** Observers (the preview window and the floating windows) notified of publishes. */ + public interface Listener { + /** A widget kind was registered (or re-registered). */ + void widgetKindRegistered(String kindId); + + /** A kind's timeline was atomically replaced; re-render its surfaces. */ + void widgetTimelinePublished(String kindId); + + /** The platform was asked to re-render from persisted timelines; kindId null means all. */ + void widgetsReloaded(String kindId); + + /** A live activity started. */ + void liveActivityStarted(String activityId); + + /** A live activity received a fresh state map. */ + void liveActivityUpdated(String activityId); + + /** A live activity ended. */ + void liveActivityEnded(String activityId, boolean dismissImmediately); + } + + /** A running live activity: descriptor document, images and the latest state map. */ + public static final class LiveActivityRecord { + private final String id; + private final Map descriptor; + private final Map images; + private Map state; + + LiveActivityRecord(String id, Map descriptor, Map images, + Map state) { + this.id = id; + this.descriptor = descriptor; + this.images = images; + this.state = state; + } + + public String getId() { + return id; + } + + /** The parsed descriptor document (type, content, island regions, ...). */ + public Map getDescriptor() { + return descriptor; + } + + public Map getImages() { + return images; + } + + /** The latest state map -- replaced wholesale by each update. */ + public Map getState() { + return state; + } + + void setState(Map state) { + this.state = state; + } + } + + private final File root; + private final boolean simulator; + private final Map> kinds = + new LinkedHashMap>(); + private final Map> timelines = + new HashMap>(); + private final Map> kindImages = + new HashMap>(); + private final Map liveActivities = + new LinkedHashMap(); + private final List listeners = new ArrayList(); + private JavaSEWidgetWindows desktopWindows; + private int nextActivityId = 1; + + /** + * Creates the bridge and restores any timelines persisted by a previous run. + * + * @param root the persistence root, e.g. {@code /cn1surfaces} + * @param simulator true in simulator mode, false in a desktop build + */ + public JavaSEWidgetBridge(File root, boolean simulator) { + this.root = root; + this.simulator = simulator; + restoreFromDisk(); + } + + // --- SurfaceBridge --------------------------------------------------------- + + @Override + public boolean areWidgetsSupported() { + return true; + } + + @Override + public boolean isLiveActivitySupported() { + return true; + } + + @Override + public void registerWidgetKind(String kindJson) { + Map kind = parse(kindJson); + if (kind == null) { + return; + } + Object id = kind.get("id"); + if (!(id instanceof String)) { + return; + } + String kindId = (String) id; + synchronized (this) { + kinds.put(kindId, kind); + } + writeFileSafely(new File(kindDir(kindId), "kind.json"), + kindJson.getBytes(StandardCharsets.UTF_8)); + for (Listener l : snapshotListeners()) { + l.widgetKindRegistered(kindId); + } + } + + @Override + public void publishWidgetTimeline(String kindId, String timelineJson, + Map images) { + Map doc = parse(timelineJson); + if (doc == null || kindId == null) { + return; + } + File dir = kindDir(kindId); + // The document's images list is the COMPLETE reference set (the serializer includes + // registered-name references to blobs shipped earlier, not just this publish's blobs). + // Resolve every referenced blob -- new bytes, the previous in-memory map, or disk -- + // so registered-name references keep rendering, and drop everything unreferenced. + Set referenced = referencedImageNames(doc); + Map imageCopy = new HashMap(); + synchronized (this) { + Map previous = kindImages.get(kindId); + for (String name : referenced) { + byte[] data = images != null ? images.get(name) : null; + if (data == null && previous != null) { + data = previous.get(name); + } + if (data == null) { + data = readFile(new File(dir, name + ".png")); + } + if (data != null) { + imageCopy.put(name, data); + } + } + timelines.put(kindId, doc); + kindImages.put(kindId, imageCopy); + } + writeFileSafely(new File(dir, "timeline.json"), + timelineJson.getBytes(StandardCharsets.UTF_8)); + for (Map.Entry e : imageCopy.entrySet()) { + File png = new File(dir, e.getKey() + ".png"); + if (!png.exists()) { + // image names are content hashes so an existing file never needs rewriting + writeFileSafely(png, e.getValue()); + } + } + // garbage collect blobs the replacement timeline no longer references: content-hash + // names would otherwise accumulate without bound for frequently changing art + File[] files = dir.listFiles(); + if (files != null) { + for (File f : files) { + String name = f.getName(); + if (name.endsWith(".png") + && !referenced.contains(name.substring(0, name.length() - 4))) { + if (!f.delete()) { + Log.p("Surfaces: failed to delete stale image " + f.getPath()); + } + } + } + } + for (Listener l : snapshotListeners()) { + l.widgetTimelinePublished(kindId); + } + } + + /// The names in the parsed timeline document's `images` list. + private static Set referencedImageNames(Map doc) { + Set referenced = new HashSet(); + Object names = doc.get("images"); + if (names instanceof java.util.Collection) { + for (Object o : (java.util.Collection) names) { + referenced.add(String.valueOf(o)); + } + } + return referenced; + } + + @Override + public void reloadWidgets(String kindId) { + for (Listener l : snapshotListeners()) { + l.widgetsReloaded(kindId); + } + } + + @Override + public int getInstalledWidgetCount(String kindId) { + if (desktopWindows != null) { + return desktopWindows.getPinnedCount(kindId); + } + // in the simulator the preview window counts as one installed instance so + // publish-skipping optimizations in app code never starve the preview + synchronized (this) { + return kinds.containsKey(kindId) ? 1 : 0; + } + } + + @Override + public String startLiveActivity(String descriptorJson, Map images) { + Map doc = parse(descriptorJson); + if (doc == null) { + return null; + } + Map imageCopy = new HashMap(); + if (images != null) { + imageCopy.putAll(images); + } + Map state = asMap(doc.get("state")); + String id; + LiveActivityRecord rec; + synchronized (this) { + id = "la" + nextActivityId++; + rec = new LiveActivityRecord(id, doc, imageCopy, state); + liveActivities.put(id, rec); + } + for (Listener l : snapshotListeners()) { + l.liveActivityStarted(id); + } + return id; + } + + @Override + public void updateLiveActivity(String activityId, String stateJson) { + LiveActivityRecord rec; + synchronized (this) { + rec = liveActivities.get(activityId); + } + if (rec == null) { + return; + } + Map state = parse(stateJson); + rec.setState(state == null ? new HashMap() : state); + for (Listener l : snapshotListeners()) { + l.liveActivityUpdated(activityId); + } + } + + @Override + public void endLiveActivity(String activityId, String finalStateJson, + boolean dismissImmediately) { + LiveActivityRecord rec; + synchronized (this) { + rec = liveActivities.remove(activityId); + } + if (rec == null) { + return; + } + if (finalStateJson != null) { + Map state = parse(finalStateJson); + if (state != null) { + rec.setState(state); + } + } + for (Listener l : snapshotListeners()) { + l.liveActivityEnded(activityId, dismissImmediately); + } + } + + // --- accessors for the surface windows -------------------------------------- + + /** True when running inside the simulator (vs a desktop app build). */ + public boolean isSimulator() { + return simulator; + } + + /** + * The ids of the known widget kinds in registration order: every registered (or + * disk-restored) kind plus any kind that published a timeline without registering first. + */ + public synchronized List getKindIds() { + List ids = new ArrayList(kinds.keySet()); + for (String id : timelines.keySet()) { + if (!ids.contains(id)) { + ids.add(id); + } + } + return ids; + } + + /** The display name of a kind, falling back to its id. */ + public synchronized String getKindDisplayName(String kindId) { + Map kind = kinds.get(kindId); + Object name = kind == null ? null : kind.get("name"); + return name instanceof String ? (String) name : kindId; + } + + /** The parsed timeline document last published for a kind, or null. */ + public synchronized Map getTimelineDoc(String kindId) { + return timelines.get(kindId); + } + + /** The PNG blobs shipped with a kind's timeline; never null. */ + public synchronized Map getKindImages(String kindId) { + Map images = kindImages.get(kindId); + return images == null ? new HashMap() : images; + } + + /** The ids of the running live activities, in start order. */ + public synchronized List getLiveActivityIds() { + return new ArrayList(liveActivities.keySet()); + } + + /** A running live activity, or null. Ended activities disappear from this lookup. */ + public synchronized LiveActivityRecord getLiveActivity(String activityId) { + return liveActivities.get(activityId); + } + + /** Registers a surface window observer. */ + public synchronized void addListener(Listener l) { + if (l != null && !listeners.contains(l)) { + listeners.add(l); + } + } + + /** Removes a surface window observer. */ + public synchronized void removeListener(Listener l) { + listeners.remove(l); + } + + /** + * Attaches the desktop floating-window manager (desktop mode only). Also used to answer + * {@link #getInstalledWidgetCount}. + */ + void setDesktopWindows(JavaSEWidgetWindows windows) { + this.desktopWindows = windows; + } + + /** + * Programmatic pinning entry point for desktop mode. There is no guaranteed tray + * infrastructure in a desktop build (the port only creates a tray icon when the app shows a + * desktop notification, and {@link JavaSEWidgetWindows#installTrayMenu} hooks it when it + * appears), so apps can pin a floating widget for the user through this call. A no-op in the + * simulator, where the Widgets preview window plays this role. + * + * @param kindId the widget kind to pin + * @param sizeName {@code small} / {@code medium} / {@code large}, null for small + */ + public void pinWidget(String kindId, String sizeName) { + if (desktopWindows != null) { + desktopWindows.pinWidget(kindId, sizeName); + } + } + + // --- shared rendering plumbing ---------------------------------------------- + + /** Receives the pixels of an asynchronous rasterization on the AWT dispatch thread. */ + public interface RenderCallback { + /** + * @param image the rasterized pixels, {@code scale}x the logical size + * @param actions hit rectangles in image (scaled) pixels + * @param nextTickMillis 0 for static content, else the epoch time a re-render is due + */ + void rendered(BufferedImage image, List actions, + long nextTickMillis); + } + + /** + * Rasterizes a descriptor node asynchronously honoring the surfaces thread rule: the + * rasterization runs on the Codename One EDT (never on the AWT thread) and the callback is + * delivered on the AWT dispatch thread for blitting. + * + * @param node parsed descriptor node (dips) + * @param state state map of the active entry / live activity + * @param images PNG blobs by wire name + * @param logicalWidth logical (dip) width + * @param logicalHeight logical (dip) height + * @param scale integer pixel scale (2 gives retina-crisp output) + * @param dark dark mode colors + * @param callback receives the result on the AWT thread + */ + public static void renderAsync(final Map node, + final Map state, final Map images, + final int logicalWidth, final int logicalHeight, final int scale, final boolean dark, + final RenderCallback callback) { + if (!Display.isInitialized()) { + return; + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + Map scaled = scaleNodeDips(node, scale); + final SurfaceRasterizer.Result result = SurfaceRasterizer.rasterize(scaled, state, + images, logicalWidth * scale, logicalHeight * scale, dark, + System.currentTimeMillis()); + final BufferedImage img = new BufferedImage(logicalWidth * scale, + logicalHeight * scale, BufferedImage.TYPE_INT_ARGB); + img.setRGB(0, 0, logicalWidth * scale, logicalHeight * scale, result.getArgb(), + 0, logicalWidth * scale); + javax.swing.SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + callback.rendered(img, result.getActions(), result.getNextTickMillis()); + } + }); + } + }); + } + + /** + * Deep-copies a descriptor node multiplying every dip-valued attribute by {@code scale}, so + * the rasterizer (which treats dips as pixels) produces a crisp scaled rendering. The action + * rectangles it returns are then in scaled pixels as well. + */ + @SuppressWarnings("unchecked") + public static Map scaleNodeDips(Map node, int scale) { + if (node == null || scale == 1) { + return node; + } + Map out = new LinkedHashMap(); + for (Map.Entry e : node.entrySet()) { + String key = e.getKey(); + Object value = e.getValue(); + if ("ch".equals(key) && value instanceof List) { + List children = new ArrayList(); + for (Object child : (List) value) { + if (child instanceof Map) { + children.add(scaleNodeDips((Map) child, scale)); + } else { + children.add(child); + } + } + out.put(key, children); + } else if ("pad".equals(key) && value instanceof List) { + List pad = new ArrayList(); + for (Object p : (List) value) { + pad.add(p instanceof Number + ? (Object) Integer.valueOf(((Number) p).intValue() * scale) : p); + } + out.put(key, pad); + } else if (isDipKey(key) && value instanceof Number) { + out.put(key, Integer.valueOf(((Number) value).intValue() * scale)); + } else { + out.put(key, value); + } + } + return out; + } + + private static boolean isDipKey(String key) { + return "w".equals(key) || "h".equals(key) || "corner".equals(key) + || "spacing".equals(key) || "min".equals(key) || "size".equals(key); + } + + // --- persistence ------------------------------------------------------------- + + private File kindDir(String kindId) { + File dir = new File(root, sanitize(kindId)); + dir.mkdirs(); + return dir; + } + + private static String sanitize(String kindId) { + StringBuilder sb = new StringBuilder(kindId.length()); + for (int i = 0; i < kindId.length(); i++) { + char c = kindId.charAt(i); + if (Character.isLetterOrDigit(c) || c == '_' || c == '-') { + sb.append(c); + } else { + sb.append('_'); + } + } + return sb.toString(); + } + + /** Atomic write-rename so the surface windows never read a torn file. */ + private static void writeFileSafely(File file, byte[] data) { + try { + File tmp = new File(file.getParentFile(), file.getName() + ".tmp"); + OutputStream os = new FileOutputStream(tmp); + try { + os.write(data); + } finally { + os.close(); + } + if (file.exists() && !file.delete()) { + return; + } + tmp.renameTo(file); + } catch (IOException err) { + err.printStackTrace(); + } + } + + private void restoreFromDisk() { + File[] dirs = root.listFiles(); + if (dirs == null) { + return; + } + for (File dir : dirs) { + if (!dir.isDirectory()) { + continue; + } + byte[] kindData = readFile(new File(dir, "kind.json")); + byte[] timelineData = readFile(new File(dir, "timeline.json")); + String kindId = dir.getName(); + if (kindData != null) { + Map kind = parse(new String(kindData, StandardCharsets.UTF_8)); + if (kind != null && kind.get("id") instanceof String) { + kindId = (String) kind.get("id"); + kinds.put(kindId, kind); + } + } + if (timelineData != null) { + Map doc = + parse(new String(timelineData, StandardCharsets.UTF_8)); + if (doc != null) { + timelines.put(kindId, doc); + kindImages.put(kindId, readImages(dir)); + } + } + } + } + + private static Map readImages(File dir) { + Map images = new HashMap(); + File[] files = dir.listFiles(); + if (files == null) { + return images; + } + for (File f : files) { + String name = f.getName(); + if (name.endsWith(".png")) { + byte[] data = readFile(f); + if (data != null) { + images.put(name.substring(0, name.length() - 4), data); + } + } + } + return images; + } + + private static byte[] readFile(File f) { + if (!f.exists()) { + return null; + } + try { + InputStream is = new FileInputStream(f); + try { + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int len = is.read(buf); + while (len > -1) { + bo.write(buf, 0, len); + len = is.read(buf); + } + return bo.toByteArray(); + } finally { + is.close(); + } + } catch (IOException err) { + err.printStackTrace(); + return null; + } + } + + private synchronized List snapshotListeners() { + return new ArrayList(listeners); + } + + @SuppressWarnings("unchecked") + private static Map parse(String json) { + if (json == null) { + return null; + } + try { + return new JSONParser().parseJSON(new StringReader(json)); + } catch (Exception err) { + err.printStackTrace(); + return null; + } + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object o) { + return o instanceof Map ? (Map) o : new HashMap(); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWidgetWindows.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWidgetWindows.java new file mode 100644 index 00000000000..6be4a1714ba --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWidgetWindows.java @@ -0,0 +1,733 @@ +/* + * 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.surfaces.SurfaceRasterizer; +import com.codename1.surfaces.Surfaces; + +import java.awt.Color; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.GraphicsEnvironment; +import java.awt.MenuItem; +import java.awt.Point; +import java.awt.PopupMenu; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.TrayIcon; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.geom.RoundRectangle2D; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.prefs.Preferences; +import javax.swing.ButtonGroup; +import javax.swing.JFrame; +import javax.swing.JMenuItem; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JRadioButtonMenuItem; +import javax.swing.JWindow; +import javax.swing.SwingUtilities; +import javax.swing.Timer; + +/** + * Desktop floating widgets: in a desktop (non-simulator) build every pinned widget kind gets a + * frameless, always-on-top {@link JWindow} that renders the kind's published timeline through + * {@link SurfaceRasterizer} -- the desktop equivalent of a home-screen widget. Windows drag + * anywhere to move, offer a right-click menu (size selection, remove) and keep rounded corners + * via {@code setShape}. Geometry and the pinned set persist in {@link Preferences} (debounced, + * following the simulator {@code AppFrame} pattern) so widgets restore where the user left them + * on the next run. Widgets are process-bound in this version: they exist while the app process + * runs (a tray keep-alive mode is a possible follow-up). + * + *

A running live activity docks a black pill window at the top-center of the primary screen + * (start / update / end), mirroring the Dynamic Island.

+ * + *

Clicks inside a rasterized action rectangle focus the main app window and dispatch through + * {@code Surfaces.dispatchAction}; clicks elsewhere start a window drag. Rasterization always + * happens on the Codename One EDT via {@link JavaSEWidgetBridge#renderAsync}; only blits touch + * the AWT thread.

+ */ +class JavaSEWidgetWindows implements JavaSEWidgetBridge.Listener { + private static final int SCALE = 2; + private static final int CORNER = 24; + private static final String[] SIZE_NAMES = {"small", "medium", "large"}; + private static final int[] SIZE_W = {158, 338, 338}; + private static final int[] SIZE_H = {158, 158, 354}; + private static final int PILL_W = 250; + private static final int PILL_H = 36; + + private final JavaSEWidgetBridge bridge; + private final JFrame mainWindow; + private final Map windows = new LinkedHashMap(); + private final Preferences prefs = Preferences.userNodeForPackage(JavaSEWidgetWindows.class); + private final String prefPrefix; + private Timer saveTimer; + private PillWindow pillWindow; + private TrayIcon trayIcon; + + JavaSEWidgetWindows(JavaSEWidgetBridge bridge, JFrame mainWindow) { + this.bridge = bridge; + this.mainWindow = mainWindow; + String mainClass = System.getProperty("MainClass"); + this.prefPrefix = "cn1.surfaces." + (mainClass == null ? "app" : mainClass) + "."; + bridge.addListener(this); + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + restorePinned(); + } + }); + } + + // --- pin management ------------------------------------------------------------ + + /** Pins a floating widget for a kind (one instance per kind). Any thread. */ + void pinWidget(final String kindId, final String sizeName) { + if (kindId == null) { + return; + } + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + WidgetWindow w = windows.get(kindId); + if (w == null) { + w = new WidgetWindow(kindId, normalizeSize(sizeName), null); + synchronized (windows) { + // mutations happen on the AWT thread; the lock only publishes them + // safely to getPinnedCount which may run on the CN1 EDT + windows.put(kindId, w); + } + w.setVisible(true); + w.requestRender(); + } else if (sizeName != null) { + w.setSizeName(normalizeSize(sizeName)); + } + savePinnedLater(); + } + }); + } + + /** Removes a pinned widget window. AWT thread. */ + void unpinWidget(String kindId) { + WidgetWindow w; + synchronized (windows) { + w = windows.remove(kindId); + } + if (w != null) { + w.close(); + } + savePinnedLater(); + } + + /** The number of pinned instances of a kind (0 or 1 in this version). */ + int getPinnedCount(String kindId) { + synchronized (windows) { + return windows.containsKey(kindId) ? 1 : 0; + } + } + + /** + * Hooks the "Add widget: ..." items into the port's persistent tray icon. The port only + * creates its tray icon lazily (for desktop notifications), so this is called when that + * happens; until then {@link JavaSEWidgetBridge#pinWidget} is the pinning entry point. + */ + void installTrayMenu(final TrayIcon tray) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + trayIcon = tray; + refreshTrayMenu(); + } + }); + } + + private void refreshTrayMenu() { + if (trayIcon == null) { + return; + } + PopupMenu menu = new PopupMenu(); + for (final String kindId : bridge.getKindIds()) { + MenuItem item = new MenuItem("Add widget: " + bridge.getKindDisplayName(kindId)); + item.addActionListener(new java.awt.event.ActionListener() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + pinWidget(kindId, null); + } + }); + menu.add(item); + } + trayIcon.setPopupMenu(menu); + } + + private void restorePinned() { + String pinned = prefs.get(prefPrefix + "pinned", ""); + if (pinned.length() == 0) { + return; + } + for (String entry : pinned.split(";")) { + int sep = entry.indexOf('|'); + if (sep <= 0) { + continue; + } + String kindId = entry.substring(0, sep); + String sizeName = normalizeSize(entry.substring(sep + 1)); + if (!windows.containsKey(kindId)) { + Point location = loadLocation(kindId); + WidgetWindow w = new WidgetWindow(kindId, sizeName, location); + synchronized (windows) { + windows.put(kindId, w); + } + w.setVisible(true); + w.requestRender(); + } + } + } + + private Point loadLocation(String kindId) { + String geom = prefs.get(prefPrefix + "geom." + kindId, null); + if (geom == null) { + return null; + } + int comma = geom.indexOf(','); + if (comma <= 0) { + return null; + } + try { + return new Point(Integer.parseInt(geom.substring(0, comma)), + Integer.parseInt(geom.substring(comma + 1))); + } catch (NumberFormatException err) { + return null; + } + } + + /** Debounced persistence of the pinned set + geometry (AppFrame pattern). */ + private void savePinnedLater() { + if (saveTimer != null) { + saveTimer.restart(); + return; + } + saveTimer = new Timer(500, new java.awt.event.ActionListener() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + savePinnedNow(); + } + }); + saveTimer.setRepeats(false); + saveTimer.start(); + } + + private void savePinnedNow() { + StringBuilder sb = new StringBuilder(); + for (WidgetWindow w : windows.values()) { + if (sb.length() > 0) { + sb.append(';'); + } + sb.append(w.kindId).append('|').append(w.sizeName); + prefs.put(prefPrefix + "geom." + w.kindId, w.getX() + "," + w.getY()); + } + prefs.put(prefPrefix + "pinned", sb.toString()); + } + + private static String normalizeSize(String sizeName) { + for (String s : SIZE_NAMES) { + if (s.equals(sizeName)) { + return s; + } + } + return "small"; + } + + private static int sizeIndex(String sizeName) { + for (int i = 0; i < SIZE_NAMES.length; i++) { + if (SIZE_NAMES[i].equals(sizeName)) { + return i; + } + } + return 0; + } + + private void focusMainWindow() { + JFrame w = mainWindow; + if (w == null) { + // desktop mode: the port never assigns its window field (the app owns the frame the + // canvas was added to), so resolve the frame from the canvas ancestry instead + JavaSEPort port = JavaSEPort.instance; + if (port != null && port.canvas != null) { + java.awt.Container top = port.canvas.getTopLevelAncestor(); + if (top instanceof JFrame) { + w = (JFrame) top; + } + } + } + if (w != null) { + requestAppForeground(); + w.setState(java.awt.Frame.NORMAL); + w.toFront(); + w.requestFocus(); + } + } + + /** + * Activates this application so toFront can actually raise the main window over other apps. + * On macOS clicking a floating widget doesn't activate the app and focus-stealing prevention + * makes a plain toFront a no-op; Desktop.requestForeground (Java 9+, invoked reflectively + * because this module compiles at an older source level) performs a real app activation. + */ + private static void requestAppForeground() { + try { + java.awt.Desktop desktop = java.awt.Desktop.getDesktop(); + java.lang.reflect.Method m = java.awt.Desktop.class.getMethod( + "requestForeground", boolean.class); + m.invoke(desktop, Boolean.TRUE); + } catch (Throwable t) { + // Java 8 runtime or platform without APP_REQUEST_FOREGROUND: best effort only + } + } + + // --- JavaSEWidgetBridge.Listener (may fire on the CN1 EDT) ------------------------- + + @Override + public void widgetKindRegistered(String kindId) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + refreshTrayMenu(); + } + }); + } + + @Override + public void widgetTimelinePublished(final String kindId) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + WidgetWindow w = windows.get(kindId); + if (w != null) { + w.requestRender(); + } + } + }); + } + + @Override + public void widgetsReloaded(final String kindId) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + for (WidgetWindow w : new ArrayList(windows.values())) { + if (kindId == null || kindId.equals(w.kindId)) { + w.requestRender(); + } + } + } + }); + } + + @Override + public void liveActivityStarted(final String activityId) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + if (pillWindow != null) { + pillWindow.close(); + } + pillWindow = new PillWindow(activityId); + pillWindow.setVisible(true); + pillWindow.requestRender(); + } + }); + } + + @Override + public void liveActivityUpdated(final String activityId) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + if (pillWindow != null && activityId.equals(pillWindow.activityId)) { + pillWindow.requestRender(); + } + } + }); + } + + @Override + public void liveActivityEnded(final String activityId, final boolean dismissImmediately) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + if (pillWindow == null || !activityId.equals(pillWindow.activityId)) { + return; + } + final PillWindow closing = pillWindow; + pillWindow = null; + if (dismissImmediately) { + closing.close(); + } else { + // linger briefly on the final state before dismissing, like the platforms do + Timer linger = new Timer(3000, new java.awt.event.ActionListener() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + closing.close(); + } + }); + linger.setRepeats(false); + linger.start(); + } + } + }); + } + + // --- the surface windows ------------------------------------------------------- + + /** Shared behavior of the floating surface windows: blitting, dragging, click dispatch. */ + private abstract class SurfaceWindow extends JWindow { + BufferedImage image; + List actions = + new ArrayList(); + Timer refreshTimer; + private Point dragOffset; + private boolean dragged; + + SurfaceWindow() { + setAlwaysOnTop(true); + final JPanel panel = new JPanel() { + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + paintSurface((Graphics2D) g); + } + }; + panel.setOpaque(false); + setContentPane(panel); + setBackground(new Color(0, 0, 0, 0)); + MouseAdapter mouse = new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + dragged = false; + dragOffset = e.getPoint(); + if (e.isPopupTrigger()) { + showContextMenu(e); + } + } + + @Override + public void mouseDragged(MouseEvent e) { + if (dragOffset != null) { + dragged = true; + setLocation(e.getXOnScreen() - dragOffset.x, + e.getYOnScreen() - dragOffset.y); + } + } + + @Override + public void mouseReleased(MouseEvent e) { + if (e.isPopupTrigger()) { + showContextMenu(e); + return; + } + if (dragged) { + moved(); + return; + } + int px = e.getX() * SCALE; + int py = e.getY() * SCALE; + for (SurfaceRasterizer.ActionRect r : actions) { + if (px >= r.getX() && px < r.getX() + r.getWidth() + && py >= r.getY() && py < r.getY() + r.getHeight()) { + focusMainWindow(); + // Surfaces.dispatchAction marshals to the CN1 EDT itself + Surfaces.dispatchAction(actionSource(), r.getActionId(), + r.getParams()); + return; + } + } + } + }; + panel.addMouseListener(mouse); + panel.addMouseMotionListener(mouse); + } + + abstract void paintSurface(Graphics2D g2); + + abstract String actionSource(); + + abstract void requestRender(); + + void showContextMenu(MouseEvent e) { + } + + void moved() { + } + + void showImage(BufferedImage img, List actionRects, + long nextTickMillis, long nextFlipMillis) { + this.image = img; + this.actions = actionRects; + repaint(); + long now = System.currentTimeMillis(); + long due = nextTickMillis; + if (nextFlipMillis > 0 && (due == 0 || nextFlipMillis < due)) { + due = nextFlipMillis; + } + if (refreshTimer != null) { + refreshTimer.stop(); + refreshTimer = null; + } + if (due > 0 && isVisible()) { + refreshTimer = new Timer((int) Math.max(50, due - now), + new java.awt.event.ActionListener() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + requestRender(); + } + }); + refreshTimer.setRepeats(false); + refreshTimer.start(); + } + } + + void close() { + if (refreshTimer != null) { + refreshTimer.stop(); + refreshTimer = null; + } + dispose(); + } + } + + /** A pinned desktop widget rendering one kind's published timeline. */ + private final class WidgetWindow extends SurfaceWindow { + final String kindId; + String sizeName; + + WidgetWindow(String kindId, String sizeName, Point location) { + this.kindId = kindId; + this.sizeName = sizeName; + applyWindowSize(); + if (location != null) { + setLocation(clampToScreen(location)); + } else { + Rectangle screen = GraphicsEnvironment.getLocalGraphicsEnvironment() + .getMaximumWindowBounds(); + int x = screen.x + screen.width - getWidth() - 40; + int y = screen.y + 60; + // cascade below existing widget windows so successive default placements + // don't stack on top of each other in the top-right corner + boolean moved = true; + while (moved) { + moved = false; + Rectangle mine = new Rectangle(x, y, getWidth(), getHeight()); + for (WidgetWindow other : windows.values()) { + if (other.getBounds().intersects(mine)) { + y = other.getY() + other.getHeight() + 16; + moved = true; + break; + } + } + } + setLocation(clampToScreen(new Point(x, y))); + } + } + + void setSizeName(String sizeName) { + this.sizeName = sizeName; + applyWindowSize(); + requestRender(); + savePinnedLater(); + } + + private void applyWindowSize() { + int index = sizeIndex(sizeName); + setSize(SIZE_W[index], SIZE_H[index]); + setShape(new RoundRectangle2D.Float(0, 0, SIZE_W[index], SIZE_H[index], + CORNER, CORNER)); + } + + private Point clampToScreen(Point p) { + Rectangle screen = GraphicsEnvironment.getLocalGraphicsEnvironment() + .getMaximumWindowBounds(); + int x = Math.max(screen.x, Math.min(p.x, screen.x + screen.width - getWidth())); + int y = Math.max(screen.y, Math.min(p.y, screen.y + screen.height - getHeight())); + return new Point(x, y); + } + + @Override + void requestRender() { + int index = sizeIndex(sizeName); + final Map doc = bridge.getTimelineDoc(kindId); + final long now = System.currentTimeMillis(); + Map layout = SurfaceRasterizer.layoutForSize(doc, sizeName); + if (layout == null) { + showImage(null, new ArrayList(), 0, 0); + return; + } + Map entry = SurfaceRasterizer.currentEntry(doc, now); + Map state = entry == null ? new HashMap() + : asMap(entry.get("state")); + JavaSEWidgetBridge.renderAsync(layout, state, bridge.getKindImages(kindId), + SIZE_W[index], SIZE_H[index], SCALE, isSystemDark(), + new JavaSEWidgetBridge.RenderCallback() { + @Override + public void rendered(BufferedImage img, + List actionRects, + long nextTickMillis) { + showImage(img, actionRects, nextTickMillis, + SurfaceRasterizer.nextEntryFlip(doc, + System.currentTimeMillis())); + } + }); + } + + @Override + String actionSource() { + return kindId; + } + + @Override + void moved() { + savePinnedLater(); + } + + @Override + void showContextMenu(MouseEvent e) { + JPopupMenu menu = new JPopupMenu(); + ButtonGroup group = new ButtonGroup(); + for (final String s : SIZE_NAMES) { + JRadioButtonMenuItem item = new JRadioButtonMenuItem( + Character.toUpperCase(s.charAt(0)) + s.substring(1), s.equals(sizeName)); + item.addActionListener(new java.awt.event.ActionListener() { + @Override + public void actionPerformed(java.awt.event.ActionEvent ev) { + setSizeName(s); + } + }); + group.add(item); + menu.add(item); + } + menu.addSeparator(); + JMenuItem remove = new JMenuItem("Remove"); + remove.addActionListener(new java.awt.event.ActionListener() { + @Override + public void actionPerformed(java.awt.event.ActionEvent ev) { + unpinWidget(kindId); + } + }); + menu.add(remove); + menu.show(e.getComponent(), e.getX(), e.getY()); + } + + @Override + void paintSurface(Graphics2D g2) { + g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(isSystemDark() ? new Color(0x1C1C1E) : Color.WHITE); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), CORNER, CORNER); + if (image != null) { + g2.drawImage(image, 0, 0, getWidth(), getHeight(), null); + } else { + g2.setColor(isSystemDark() ? Color.WHITE : new Color(0x1C1C1E)); + String name = bridge.getKindDisplayName(kindId); + int w = g2.getFontMetrics().stringWidth(name); + g2.drawString(name, (getWidth() - w) / 2, getHeight() / 2); + } + } + } + + /** The live activity pill docked at the top-center of the primary screen. */ + private final class PillWindow extends SurfaceWindow { + final String activityId; + + PillWindow(String activityId) { + this.activityId = activityId; + setSize(PILL_W, PILL_H); + setShape(new RoundRectangle2D.Float(0, 0, PILL_W, PILL_H, PILL_H, PILL_H)); + Rectangle screen = GraphicsEnvironment.getLocalGraphicsEnvironment() + .getMaximumWindowBounds(); + setLocation(screen.x + (screen.width - PILL_W) / 2, screen.y + 8); + } + + @Override + void requestRender() { + JavaSEWidgetBridge.LiveActivityRecord rec = bridge.getLiveActivity(activityId); + if (rec == null) { + // ended: the final state was already applied to the record before removal, keep + // whatever pixels we have during the linger period + return; + } + Map pillNode = SimulatorWidgets.buildPillNode(rec.getDescriptor()); + JavaSEWidgetBridge.renderAsync(pillNode, rec.getState(), rec.getImages(), + PILL_W, PILL_H, SCALE, true, new JavaSEWidgetBridge.RenderCallback() { + @Override + public void rendered(BufferedImage img, + List actionRects, + long nextTickMillis) { + showImage(img, actionRects, nextTickMillis, 0); + } + }); + } + + @Override + String actionSource() { + JavaSEWidgetBridge.LiveActivityRecord rec = bridge.getLiveActivity(activityId); + Object type = rec == null ? null : rec.getDescriptor().get("type"); + return type instanceof String ? (String) type : activityId; + } + + @Override + void paintSurface(Graphics2D g2) { + g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(Color.BLACK); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), getHeight(), getHeight()); + if (image != null) { + g2.drawImage(image, 0, 0, getWidth(), getHeight(), null); + } + } + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object o) { + return o instanceof Map ? (Map) o : new HashMap(); + } + + /** Follows the simulator's dark-mode flag when available; defaults to light. */ + private static boolean isSystemDark() { + try { + com.codename1.ui.Display d = com.codename1.ui.Display.getInstance(); + Boolean dark = d.isDarkMode(); + return dark != null && dark.booleanValue(); + } catch (Throwable t) { + return false; + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/SimulatorWidgets.java b/Ports/JavaSE/src/com/codename1/impl/javase/SimulatorWidgets.java new file mode 100644 index 00000000000..48437b6beda --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/SimulatorWidgets.java @@ -0,0 +1,597 @@ +/* + * 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.surfaces.SurfaceRasterizer; +import com.codename1.surfaces.Surfaces; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.swing.BorderFactory; +import javax.swing.BoxLayout; +import javax.swing.DefaultListModel; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.ListSelectionModel; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import javax.swing.WindowConstants; +import javax.swing.event.ListSelectionEvent; +import javax.swing.event.ListSelectionListener; + +/** + * The simulator's Widgets preview window (pattern: {@link JavaSECarBridge}'s auxiliary head-unit + * window): a kind list on the left, a size selector and light/dark toggle on top, and a canvas + * that blits the {@link SurfaceRasterizer} output of the currently published timeline. Timeline + * entry flips auto-advance on schedule and countdown content re-renders at the rasterizer's tick + * cadence, so developers watch exactly what a home-screen widget would do -- without leaving the + * simulator. + * + *

A mock Dynamic Island pill plus an expanded card at the bottom render running live + * activities (start / update / end from {@code com.codename1.surfaces.LiveActivity}).

+ * + *

Thread rule: rasterization always runs on the Codename One EDT + * ({@link JavaSEWidgetBridge#renderAsync}); this class only blits on the AWT thread. Clicks map + * through the rasterizer's action rectangles into + * {@code com.codename1.surfaces.Surfaces.dispatchAction(...)}.

+ */ +class SimulatorWidgets implements JavaSEWidgetBridge.Listener { + /** Rasterization scale: 2x logical points for retina crispness. */ + private static final int SCALE = 2; + private static final int PILL_W = 250; + private static final int PILL_H = 36; + private static final int EXPANDED_W = 350; + private static final int EXPANDED_H = 160; + + private static final String[] SIZE_NAMES = {"small", "medium", "large"}; + private static final int[] SIZE_W = {158, 338, 338}; + private static final int[] SIZE_H = {158, 158, 354}; + + private static SimulatorWidgets instance; + + private final JavaSEWidgetBridge bridge; + private JFrame frame; + private JList kindList; + private final DefaultListModel kindModel = new DefaultListModel(); + private final List kindIds = new ArrayList(); + private JComboBox sizeCombo; + private JCheckBox darkToggle; + private SurfacePanel widgetPanel; + private JLabel timelineLabel; + private SurfacePanel pillPanel; + private SurfacePanel expandedPanel; + private JLabel activityLabel; + private Timer refreshTimer; + private Timer activityTimer; + + /** Opens (or focuses) the preview window. Call on the AWT thread. */ + static void showWindow(JavaSEWidgetBridge bridge, JFrame owner) { + if (instance == null) { + instance = new SimulatorWidgets(bridge); + instance.open(owner); + } else { + instance.frame.setVisible(true); + instance.frame.toFront(); + instance.refreshKinds(); + instance.requestRender(); + } + } + + private SimulatorWidgets(JavaSEWidgetBridge bridge) { + this.bridge = bridge; + } + + private void open(JFrame owner) { + frame = new JFrame("Widgets"); + frame.setDefaultCloseOperation(WindowConstants.HIDE_ON_CLOSE); + + kindList = new JList(kindModel); + kindList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + kindList.addListSelectionListener(new ListSelectionListener() { + @Override + public void valueChanged(ListSelectionEvent e) { + if (!e.getValueIsAdjusting()) { + requestRender(); + } + } + }); + JScrollPane kindScroll = new JScrollPane(kindList); + kindScroll.setPreferredSize(new Dimension(180, 200)); + kindScroll.setBorder(BorderFactory.createTitledBorder("Widget kinds")); + + sizeCombo = new JComboBox(new String[] {"Small", "Medium", "Large"}); + sizeCombo.addActionListener(new java.awt.event.ActionListener() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + requestRender(); + } + }); + darkToggle = new JCheckBox("Dark mode"); + darkToggle.addActionListener(new java.awt.event.ActionListener() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + requestRender(); + requestActivityRender(); + } + }); + JPanel toolbar = new JPanel(new FlowLayout(FlowLayout.LEFT)); + toolbar.add(new JLabel("Size:")); + toolbar.add(sizeCombo); + toolbar.add(darkToggle); + + widgetPanel = new SurfacePanel(SIZE_W[1], SIZE_H[1], 20, true); + widgetPanel.setSourceLookup(new SourceLookup() { + @Override + public String sourceId() { + return selectedKindId(); + } + }); + timelineLabel = new JLabel("No timeline published yet", javax.swing.SwingConstants.CENTER); + + JPanel center = new JPanel(new BorderLayout()); + center.add(toolbar, BorderLayout.NORTH); + JPanel canvasWrap = new JPanel(new FlowLayout(FlowLayout.CENTER, 20, 20)); + canvasWrap.add(widgetPanel); + center.add(canvasWrap, BorderLayout.CENTER); + center.add(timelineLabel, BorderLayout.SOUTH); + + // mock Dynamic Island: a dark pill (compact leading/trailing regions) + expanded card + pillPanel = new SurfacePanel(PILL_W, PILL_H, PILL_H, false); + pillPanel.setPillBackground(true); + expandedPanel = new SurfacePanel(EXPANDED_W, EXPANDED_H, 24, false); + activityLabel = new JLabel("No live activity running", javax.swing.SwingConstants.CENTER); + JPanel island = new JPanel(); + island.setLayout(new BoxLayout(island, BoxLayout.Y_AXIS)); + island.setBorder(BorderFactory.createTitledBorder("Live activity / Dynamic Island")); + JPanel pillWrap = new JPanel(new FlowLayout(FlowLayout.CENTER, 0, 4)); + pillWrap.add(pillPanel); + JPanel expandedWrap = new JPanel(new FlowLayout(FlowLayout.CENTER, 0, 4)); + expandedWrap.add(expandedPanel); + island.add(activityLabel); + island.add(pillWrap); + island.add(expandedWrap); + + frame.getContentPane().setLayout(new BorderLayout(8, 8)); + frame.getContentPane().add(kindScroll, BorderLayout.WEST); + frame.getContentPane().add(center, BorderLayout.CENTER); + frame.getContentPane().add(island, BorderLayout.SOUTH); + frame.pack(); + frame.setSize(Math.max(frame.getWidth(), 660), Math.max(frame.getHeight(), 720)); + frame.setLocationRelativeTo(owner); + frame.setVisible(true); + + bridge.addListener(this); + refreshKinds(); + requestRender(); + requestActivityRender(); + } + + // --- JavaSEWidgetBridge.Listener (may fire on the CN1 EDT) -------------------- + + @Override + public void widgetKindRegistered(final String kindId) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + refreshKinds(); + } + }); + } + + @Override + public void widgetTimelinePublished(final String kindId) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + refreshKinds(); + if (kindId != null && kindId.equals(selectedKindId())) { + requestRender(); + } + } + }); + } + + @Override + public void widgetsReloaded(String kindId) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + requestRender(); + } + }); + } + + @Override + public void liveActivityStarted(String activityId) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + requestActivityRender(); + } + }); + } + + @Override + public void liveActivityUpdated(String activityId) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + requestActivityRender(); + } + }); + } + + @Override + public void liveActivityEnded(String activityId, boolean dismissImmediately) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + requestActivityRender(); + } + }); + } + + // --- widget rendering ---------------------------------------------------------- + + private void refreshKinds() { + String selected = selectedKindId(); + kindIds.clear(); + kindIds.addAll(bridge.getKindIds()); + kindModel.clear(); + int selectIndex = kindIds.isEmpty() ? -1 : 0; + for (int i = 0; i < kindIds.size(); i++) { + String id = kindIds.get(i); + kindModel.addElement(bridge.getKindDisplayName(id)); + if (id.equals(selected)) { + selectIndex = i; + } + } + if (selectIndex >= 0) { + kindList.setSelectedIndex(selectIndex); + } + } + + private String selectedKindId() { + int index = kindList == null ? -1 : kindList.getSelectedIndex(); + return index >= 0 && index < kindIds.size() ? kindIds.get(index) : null; + } + + private String selectedSizeName() { + int index = Math.max(0, sizeCombo.getSelectedIndex()); + return SIZE_NAMES[index]; + } + + /** Re-rasterizes the selected kind at the selected size; safe to call from any thread. */ + private void requestRender() { + if (frame == null || !frame.isVisible()) { + return; + } + final String kindId = selectedKindId(); + final String sizeName = selectedSizeName(); + final int sizeIndex = Math.max(0, sizeCombo.getSelectedIndex()); + final boolean dark = darkToggle.isSelected(); + final Map doc = kindId == null ? null : bridge.getTimelineDoc(kindId); + final long now = System.currentTimeMillis(); + final Map layout = SurfaceRasterizer.layoutForSize(doc, sizeName); + widgetPanel.setLogicalSize(SIZE_W[sizeIndex], SIZE_H[sizeIndex]); + if (layout == null) { + widgetPanel.showImage(null, new ArrayList()); + updateTimelineLabel(doc, now); + return; + } + Map entry = SurfaceRasterizer.currentEntry(doc, now); + Map state = entry == null ? new HashMap() + : asMap(entry.get("state")); + JavaSEWidgetBridge.renderAsync(layout, state, bridge.getKindImages(kindId), + SIZE_W[sizeIndex], SIZE_H[sizeIndex], SCALE, dark, + new JavaSEWidgetBridge.RenderCallback() { + @Override + public void rendered(BufferedImage image, + List actions, long nextTickMillis) { + widgetPanel.showImage(image, actions); + updateTimelineLabel(doc, System.currentTimeMillis()); + scheduleRefresh(doc, nextTickMillis); + } + }); + } + + private void updateTimelineLabel(Map doc, long now) { + if (doc == null) { + timelineLabel.setText("No timeline published yet -- call Surfaces.publish(...)"); + return; + } + List entries = doc.get("entries") instanceof List ? (List) doc.get("entries") + : new ArrayList(); + Map current = SurfaceRasterizer.currentEntry(doc, now); + int index = current == null ? -1 : entries.indexOf(current); + long flip = SurfaceRasterizer.nextEntryFlip(doc, now); + StringBuilder sb = new StringBuilder(); + sb.append("Timeline: entry ").append(index + 1).append(" of ").append(entries.size()); + if (flip > 0) { + sb.append(", next flip in ").append(Math.max(0, (flip - now) / 1000)).append("s"); + } + timelineLabel.setText(sb.toString()); + } + + /** Arms one swing timer at the earlier of the rasterizer tick and the next entry flip. */ + private void scheduleRefresh(Map doc, long nextTickMillis) { + long now = System.currentTimeMillis(); + long due = nextTickMillis; + long flip = SurfaceRasterizer.nextEntryFlip(doc, now); + if (flip > 0 && (due == 0 || flip < due)) { + due = flip; + } + if (refreshTimer != null) { + refreshTimer.stop(); + refreshTimer = null; + } + if (due <= 0 || !frame.isVisible()) { + return; + } + refreshTimer = new Timer((int) Math.max(50, due - now), new java.awt.event.ActionListener() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + requestRender(); + } + }); + refreshTimer.setRepeats(false); + refreshTimer.start(); + } + + // --- live activity rendering ------------------------------------------------------ + + private void requestActivityRender() { + List ids = bridge.getLiveActivityIds(); + String activityId = ids.isEmpty() ? null : ids.get(ids.size() - 1); + final JavaSEWidgetBridge.LiveActivityRecord rec = + activityId == null ? null : bridge.getLiveActivity(activityId); + if (activityTimer != null) { + activityTimer.stop(); + activityTimer = null; + } + if (rec == null) { + activityLabel.setText("No live activity running"); + pillPanel.showImage(null, new ArrayList()); + expandedPanel.showImage(null, new ArrayList()); + return; + } + Object type = rec.getDescriptor().get("type"); + activityLabel.setText("Live activity: " + (type instanceof String ? (String) type : "?")); + pillPanel.setSourceLookupValue(type instanceof String ? (String) type : null); + expandedPanel.setSourceLookupValue(type instanceof String ? (String) type : null); + Map pillNode = buildPillNode(rec.getDescriptor()); + // the island pill is always dark, matching the hardware cutout it hugs + JavaSEWidgetBridge.renderAsync(pillNode, rec.getState(), rec.getImages(), + PILL_W, PILL_H, SCALE, true, new JavaSEWidgetBridge.RenderCallback() { + @Override + public void rendered(BufferedImage image, + List actions, long nextTickMillis) { + pillPanel.showImage(image, actions); + scheduleActivityRefresh(nextTickMillis); + } + }); + Map content = asMap(rec.getDescriptor().get("content")); + if (!content.isEmpty()) { + JavaSEWidgetBridge.renderAsync(content, rec.getState(), rec.getImages(), + EXPANDED_W, EXPANDED_H, SCALE, darkToggle.isSelected(), + new JavaSEWidgetBridge.RenderCallback() { + @Override + public void rendered(BufferedImage image, + List actions, long nextTickMillis) { + expandedPanel.showImage(image, actions); + scheduleActivityRefresh(nextTickMillis); + } + }); + } + } + + private void scheduleActivityRefresh(long nextTickMillis) { + long now = System.currentTimeMillis(); + if (nextTickMillis <= 0 || !frame.isVisible()) { + return; + } + if (activityTimer != null && activityTimer.isRunning()) { + return; + } + activityTimer = new Timer((int) Math.max(50, nextTickMillis - now), + new java.awt.event.ActionListener() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + requestActivityRender(); + } + }); + activityTimer.setRepeats(false); + activityTimer.start(); + } + + /** + * Synthesizes the compact Dynamic Island pill layout: compact leading and trailing regions + * pushed apart by a spacer, falling back to the minimal region or the activity type name. + */ + static Map buildPillNode(Map descriptor) { + Map island = asMap(descriptor.get("island")); + Object leading = island.get("compactLeading"); + Object trailing = island.get("compactTrailing"); + Map row = new LinkedHashMap(); + row.put("t", "row"); + List pad = new ArrayList(); + pad.add(Integer.valueOf(4)); + pad.add(Integer.valueOf(14)); + pad.add(Integer.valueOf(4)); + pad.add(Integer.valueOf(14)); + row.put("pad", pad); + row.put("spacing", Integer.valueOf(8)); + List children = new ArrayList(); + if (leading instanceof Map || trailing instanceof Map) { + if (leading instanceof Map) { + children.add(leading); + } + Map spacer = new LinkedHashMap(); + spacer.put("t", "spacer"); + children.add(spacer); + if (trailing instanceof Map) { + children.add(trailing); + } + } else if (island.get("minimal") instanceof Map) { + children.add(island.get("minimal")); + } else { + Map label = new LinkedHashMap(); + label.put("t", "text"); + Object type = descriptor.get("type"); + label.put("text", type instanceof String ? type : "live activity"); + label.put("size", Integer.valueOf(12)); + children.add(label); + } + row.put("ch", children); + return row; + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object o) { + return o instanceof Map ? (Map) o : new HashMap(); + } + + // --- the blit panel ------------------------------------------------------------- + + private interface SourceLookup { + String sourceId(); + } + + /** + * Paints the rasterized ARGB output at logical size (the backing image is {@code SCALE}x for + * retina crispness) and maps clicks through the scaled action rectangles into + * {@code Surfaces.dispatchAction}. + */ + private static final class SurfacePanel extends JPanel { + private BufferedImage image; + private List actions = + new ArrayList(); + private int logicalWidth; + private int logicalHeight; + private final boolean checkerBackdrop; + private boolean pillBackground; + private SourceLookup sourceLookup; + private String sourceLookupValue; + + SurfacePanel(int logicalWidth, int logicalHeight, int cornerRadius, + boolean checkerBackdrop) { + this.logicalWidth = logicalWidth; + this.logicalHeight = logicalHeight; + this.checkerBackdrop = checkerBackdrop; + setPreferredSize(new Dimension(logicalWidth, logicalHeight)); + setOpaque(false); + addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + dispatchClick(e.getX(), e.getY()); + } + }); + } + + void setSourceLookup(SourceLookup lookup) { + this.sourceLookup = lookup; + } + + void setSourceLookupValue(String value) { + this.sourceLookupValue = value; + } + + void setPillBackground(boolean pill) { + this.pillBackground = pill; + } + + void setLogicalSize(int w, int h) { + if (w != logicalWidth || h != logicalHeight) { + logicalWidth = w; + logicalHeight = h; + setPreferredSize(new Dimension(w, h)); + revalidate(); + } + } + + void showImage(BufferedImage img, List actionRects) { + this.image = img; + this.actions = actionRects; + setCursor(Cursor.getPredefinedCursor( + actionRects.isEmpty() ? Cursor.DEFAULT_CURSOR : Cursor.HAND_CURSOR)); + repaint(); + } + + private void dispatchClick(int x, int y) { + // the click arrives in logical pixels; the action rects are in SCALE-d image pixels + int px = x * SCALE; + int py = y * SCALE; + for (SurfaceRasterizer.ActionRect r : actions) { + if (px >= r.getX() && px < r.getX() + r.getWidth() + && py >= r.getY() && py < r.getY() + r.getHeight()) { + String source = sourceLookupValue != null ? sourceLookupValue + : (sourceLookup == null ? null : sourceLookup.sourceId()); + // Surfaces.dispatchAction marshals to the CN1 EDT itself + Surfaces.dispatchAction(source, r.getActionId(), r.getParams()); + return; + } + } + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR); + if (checkerBackdrop) { + g2.setColor(new Color(0xEDEDED)); + g2.fillRoundRect(0, 0, logicalWidth, logicalHeight, 20, 20); + } + if (pillBackground) { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(Color.BLACK); + g2.fillRoundRect(0, 0, logicalWidth, logicalHeight, logicalHeight, logicalHeight); + } + if (image != null) { + g2.drawImage(image, 0, 0, logicalWidth, logicalHeight, null); + } + g2.dispose(); + } + } +} diff --git a/Ports/JavaScriptPort/STATUS.md b/Ports/JavaScriptPort/STATUS.md index 48838e756b0..55a739335d2 100644 --- a/Ports/JavaScriptPort/STATUS.md +++ b/Ports/JavaScriptPort/STATUS.md @@ -129,7 +129,24 @@ Lasting fixes shipped on this branch - `CLASS_WIPE` — cached wrapper class about to be overwritten with null -8. **Initializr "Generate" end-to-end** (`dfff3e809` and the followups): +8. **`StringBuilder.append(Object)` virtual toString dispatch** + (`vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js:5257`): + the native used the synchronous `jvm.toNativeString(obj)`, whose + last-resort fallback is `"" + value` -- for any heap object (boxed + Integer/Long/Double included) that yields the JS + `"[object Object]"` instead of the Java `toString()`. JSONWriter + emits numbers via `sb.append(o)`, so every serialized number became + `[object Object]` -- the `[` opened a phantom JSON array on re-parse + and mangled the whole document (the `surfacesJsonRoundTrip` / + `surfacesRasterizerNpe` park reasons, plus the previously-failing + SurfacesTimelineLogicTest). The append(Object) native is now a + generator: strings keep the fast sync path, everything else goes + through `runtimeToNativeString`'s virtual `toString()` dispatch. + All three surfaces tests are un-parked; A/B full-suite runs (old vs + new runtime, same bundle) delivered byte-identical PNGs for all 135 + other tests. + +9. **Initializr "Generate" end-to-end** (`dfff3e809` and the followups): 5 layered workarounds for build-script path, HTTP-status check in `getArrayBufferInputStream`, custom `DownloadNative`/`InflateNative` bypasses for broken LocalForage and zipme codepaths, manual STORED diff --git a/Ports/LinuxPort/nativeSources/cn1_linux.h b/Ports/LinuxPort/nativeSources/cn1_linux.h index 97aa5f4a8a7..f1788f2759c 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux.h +++ b/Ports/LinuxPort/nativeSources/cn1_linux.h @@ -98,6 +98,20 @@ void cn1LinuxPushEvent(int type, int x, int y, int keyCode); */ int cn1LinuxPopEvent(int* out); +/* --------------------------------------------------------------- widgets */ + +/* + * Surfaces applet windows (cn1_linux_widgets.c): frameless GTK toplevels that + * display rasterized widget/live-activity pixels outside the main window. + * Events queue as ";click;;" / ";moved;;" strings drained + * by the main-thread input pump. The returned string is owned by the caller + * (g_free), NULL when the queue is empty. + */ +char* cn1LinuxWidgetPollEvent(void); + +/* Presents (raises + focuses) the main app window; marshaled to the GTK loop. */ +void cn1LinuxWidgetFocusApp(void); + /* ------------------------------------------------------------- resources */ /* diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_widgets.c b/Ports/LinuxPort/nativeSources/cn1_linux_widgets.c new file mode 100644 index 00000000000..79a9a646638 --- /dev/null +++ b/Ports/LinuxPort/nativeSources/cn1_linux_widgets.c @@ -0,0 +1,828 @@ +/* + * 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. + */ + +/* + * Desktop "applet" widget windows for the com.codename1.surfaces framework: + * each pinned widget (and the live-activity pill) is a frameless GTK3 toplevel + * that blits ARGB pixels rasterized on the Codename One EDT by the shared + * SurfaceRasterizer. LinuxWidgetBridge owns the lifecycle from the Java side. + * + * Window recipe: undecorated + GDK_WINDOW_TYPE_HINT_UTILITY + keep-above + + * stick (all workspaces) + skip-taskbar/pager, app-paintable with the screen's + * RGBA visual so the rounded corners baked into the pixel alpha actually + * composite as translucency (null-checked fallback to the default visual on + * non-compositing X servers -- corners then render on an opaque black square). + * + * Threading: the Codename One EDT calls the LinuxNative widget* bridges below; + * they never touch GTK directly. Every GTK operation is marshaled onto the GTK + * main loop with gdk_threads_add_idle + a heap payload (the port's documented + * pattern, see cn1_linux_window.c). Idle callbacks run in enqueue order, so + * create -> update -> move -> destroy sequencing needs no extra handshakes; a + * mutex-guarded static slot table hands out ids synchronously while the window + * itself materializes asynchronously. Events travel back through a + * mutex-guarded string queue drained by the main-thread input pump + * (LinuxImplementation.drainInput -> LinuxNative.widgetPollEvent), formatted + * ";click;;" / ";moved;;" in window (logical) coordinates. + * + * Pixel format: cairo CAIRO_FORMAT_ARGB32 is PREMULTIPLIED alpha in a + * native-endian 32-bit word; the Java side ships straight-alpha CN1 ARGB, so + * the bridge premultiplies while copying. Reading/writing whole uint32 values + * keeps the channel layout correct on both little and big endian hosts. + * + * WAYLAND: a Wayland compositor exposes no global window positioning, no + * keep-above and no stick to ordinary clients -- gtk_window_move / + * gtk_window_set_keep_above / gtk_window_stick are silent no-ops there, and + * configure-event reports (0,0). The behavior matrix is therefore: + * + * - X11 (and XWayland): the EWMH recipe above; gtk_window_move positions + * globally, configure-event feeds the position cache, interactive moves go + * through gtk_window_begin_move_drag. Unchanged. + * - Wayland + layer-shell (wlroots family: Sway/Hyprland/Wayfire, and KDE + * Plasma): gtk-layer-shell is dlopen'd (never linked, mirroring the + * optional-library convention of cn1_linux_services.c) and each window + * becomes a zwlr_layer_shell_v1 surface -- widgets on LAYER_BOTTOM (above + * the wallpaper, under normal windows, the conventional desktop-applet + * slot), the live-activity pill on LAYER_TOP. Position maps to LEFT/TOP + * anchors + margins, so the persisted x/y round-trips as margins; the + * pill's x == -1 centering sentinel anchors TOP only (layer-shell centers + * a surface anchored to neither horizontal edge). Layer surfaces cannot + * use gtk_window_begin_move_drag, so dragging is manual: a press outside + * the hit-rects starts an implicit-grab drag that live-updates the margins + * from motion deltas and pushes "moved" for persistence on release. + * - Wayland without layer-shell (GNOME Mutter, or the library missing): + * plain floating windows the user positions manually via the + * compositor-driven gtk_window_begin_move_drag; no global placement. + * + * Like the rest of this port, this unit has not yet been exercised on real + * GTK hardware. + */ + +#include "cn1_linux_gfx.h" +#include +#include +#include +#include +#include + +extern JAVA_OBJECT newStringFromCString(CODENAME_ONE_THREAD_STATE, const char* str); +extern GtkWidget* cn1LinuxWindowWidget(void); + +/* --- gtk-layer-shell (libgtk-layer-shell.so.0) ------------------------------ + * dlopen'd at first widget creation, following the optional-library loader + * convention of cn1_linux_services.c (libnotify et al). One deliberate + * difference: the services unit includes the optional headers and types its + * pointers with __typeof__, but gtk-layer-shell's -dev package is far less + * ubiquitous than libnotify's, so the handful of entry points and enum values + * are declared here verbatim from gtk-layer-shell.h (they are stable ABI since + * 0.6). A missing library, a missing symbol or gtk_layer_is_supported() == + * FALSE all leave cn1_layershell_state at -1 and the pre-existing EWMH code + * path runs untouched. */ + +#define CN1_LAYER_SHELL_LAYER_BOTTOM 1 /* GTK_LAYER_SHELL_LAYER_BOTTOM */ +#define CN1_LAYER_SHELL_LAYER_TOP 2 /* GTK_LAYER_SHELL_LAYER_TOP */ +#define CN1_LAYER_SHELL_EDGE_LEFT 0 /* GTK_LAYER_SHELL_EDGE_LEFT */ +#define CN1_LAYER_SHELL_EDGE_RIGHT 1 /* GTK_LAYER_SHELL_EDGE_RIGHT */ +#define CN1_LAYER_SHELL_EDGE_TOP 2 /* GTK_LAYER_SHELL_EDGE_TOP */ +#define CN1_LAYER_SHELL_KEYBOARD_MODE_NONE 0 /* GTK_LAYER_SHELL_KEYBOARD_MODE_NONE */ + +/* The real signatures take GtkLayerShellLayer/Edge/KeyboardMode enums; plain C + * enums have int-compatible calling conventions on every ABI this port targets, + * so int parameters keep these declarations header-free. */ +static void (*p_gtk_layer_init_for_window)(GtkWindow* window); +static gboolean (*p_gtk_layer_is_supported)(void); +static void (*p_gtk_layer_set_layer)(GtkWindow* window, int layer); +static void (*p_gtk_layer_set_anchor)(GtkWindow* window, int edge, gboolean anchorToEdge); +static void (*p_gtk_layer_set_margin)(GtkWindow* window, int edge, int marginSize); +static void (*p_gtk_layer_set_exclusive_zone)(GtkWindow* window, int exclusiveZone); +static void (*p_gtk_layer_set_keyboard_mode)(GtkWindow* window, int mode); +static int cn1_layershell_state = 0; /* 0 = untried, 1 = active, -1 = unavailable */ + +/* GTK main thread only: gtk_layer_is_supported() inspects the default display, + * and the state variable is unsynchronized by design (every reader runs on the + * GTK main loop). Returns 1 when layer-shell surfaces can actually be created. */ +static int cn1LoadLayerShell(void) { + void* h; + int ok = 1; + if (cn1_layershell_state) { + return cn1_layershell_state > 0; + } + h = dlopen("libgtk-layer-shell.so.0", RTLD_LAZY | RTLD_GLOBAL); + if (!h) { + h = dlopen("libgtk-layer-shell.so", RTLD_LAZY | RTLD_GLOBAL); + } + if (!h) { + cn1_layershell_state = -1; + if (g_getenv("WAYLAND_DISPLAY")) { + cn1LinuxStubOnce("gtk-layer-shell not installed; Wayland widgets degrade to plain floating windows"); + } + return 0; + } +#define CN1_LAYER_SYM(ptr, name) do { *(void**)(&ptr) = dlsym(h, name); if (!(ptr)) { ok = 0; } } while (0) + CN1_LAYER_SYM(p_gtk_layer_init_for_window, "gtk_layer_init_for_window"); + CN1_LAYER_SYM(p_gtk_layer_is_supported, "gtk_layer_is_supported"); + CN1_LAYER_SYM(p_gtk_layer_set_layer, "gtk_layer_set_layer"); + CN1_LAYER_SYM(p_gtk_layer_set_anchor, "gtk_layer_set_anchor"); + CN1_LAYER_SYM(p_gtk_layer_set_margin, "gtk_layer_set_margin"); + CN1_LAYER_SYM(p_gtk_layer_set_exclusive_zone, "gtk_layer_set_exclusive_zone"); + CN1_LAYER_SYM(p_gtk_layer_set_keyboard_mode, "gtk_layer_set_keyboard_mode"); +#undef CN1_LAYER_SYM + if (!ok) { + cn1_layershell_state = -1; + cn1LinuxStubOnce("gtk-layer-shell present but an expected symbol was missing; Wayland widgets degrade to plain floating windows"); + return 0; + } + if (!p_gtk_layer_is_supported()) { + /* X11 session (layer-shell is a Wayland protocol) or a Wayland + * compositor without zwlr_layer_shell_v1 (GNOME Mutter). Quiet: this is + * the normal, documented degradation, not a missing dependency. */ + cn1_layershell_state = -1; + return 0; + } + cn1_layershell_state = 1; + return 1; +} + +/* ------------------------------------------------------------- slot table */ + +#define CN1_WIDGET_SLOTS 64 + +typedef struct { + /* Allocated synchronously on the calling (EDT) thread under cn1WidgetLock; + * cleared only by the destroy op on the GTK thread, so a slot can never be + * recycled while the old window's signal handlers might still fire. */ + gint64 id; /* nonzero when allocated */ + GtkWidget* window; /* GTK main thread only */ + cairo_surface_t* surface; /* premultiplied ARGB32; GTK main thread only */ + int winW, winH; /* logical window size */ + int imgW, imgH; /* pixel-buffer size (draw scales to winW/winH) */ + int* hitRects; /* x,y,w,h quads, window coords; cn1WidgetLock */ + int hitRectCount; /* number of quads; cn1WidgetLock */ + int x, y; /* last known window position; cn1WidgetLock. + * Under layer-shell these are the LEFT/TOP + * margins (x == -1 = centered horizontally). */ + gint64 lastMovePush; /* monotonic us of the last "moved" event; GTK thread */ + int layered; /* window is a layer-shell surface; GTK thread */ + int dragging; /* manual layer-shell drag in flight; GTK thread */ + double dragPressX, dragPressY; /* press point, window coords; GTK thread */ +} CN1WidgetSlot; + +static CN1WidgetSlot cn1Widgets[CN1_WIDGET_SLOTS]; +static pthread_mutex_t cn1WidgetLock = PTHREAD_MUTEX_INITIALIZER; +static gint64 cn1WidgetNextId = 1; + +/* Push at most one "moved" event per this interval (microseconds); the Java + * side re-reads the authoritative position via widgetGetX/Y when persisting, + * so a swallowed trailing configure-event cannot leave stale geometry. */ +#define CN1_WIDGET_MOVE_THROTTLE_US 200000 + +/* Returns the slot currently bound to id, or NULL. Caller holds cn1WidgetLock. */ +static CN1WidgetSlot* cn1WidgetById(gint64 id) { + int i; + if (id == 0) { + return 0; + } + for (i = 0; i < CN1_WIDGET_SLOTS; i++) { + if (cn1Widgets[i].id == id) { + return &cn1Widgets[i]; + } + } + return 0; +} + +/* ------------------------------------------------------------ event queue */ + +#define CN1_WIDGET_EVENT_QUEUE 128 +static char* cn1WidgetEvents[CN1_WIDGET_EVENT_QUEUE]; +static int cn1WidgetEvHead = 0; +static int cn1WidgetEvTail = 0; +static pthread_mutex_t cn1WidgetEventLock = PTHREAD_MUTEX_INITIALIZER; + +/* Queues ";;;" for the Java side; drops (frees) when full. */ +static void cn1WidgetPushEvent(gint64 id, const char* kind, int x, int y) { + char* msg = g_strdup_printf("%" G_GINT64_FORMAT ";%s;%d;%d", id, kind, x, y); + int next; + pthread_mutex_lock(&cn1WidgetEventLock); + next = (cn1WidgetEvTail + 1) % CN1_WIDGET_EVENT_QUEUE; + if (next != cn1WidgetEvHead) { + cn1WidgetEvents[cn1WidgetEvTail] = msg; + cn1WidgetEvTail = next; + } else { + g_free(msg); + } + pthread_mutex_unlock(&cn1WidgetEventLock); +} + +/* Next queued widget event, or NULL. The caller owns the string (g_free). */ +char* cn1LinuxWidgetPollEvent(void) { + char* msg = 0; + pthread_mutex_lock(&cn1WidgetEventLock); + if (cn1WidgetEvHead != cn1WidgetEvTail) { + msg = cn1WidgetEvents[cn1WidgetEvHead]; + cn1WidgetEvHead = (cn1WidgetEvHead + 1) % CN1_WIDGET_EVENT_QUEUE; + } + pthread_mutex_unlock(&cn1WidgetEventLock); + return msg; +} + +/* Applies an x/y position to a layer-shell surface: anchored to the output's + * top-left corner with the coordinates as margins, so the persisted geometry + * round-trips 1:1 through widgetGetX/Y. x == -1 (the live-activity pill's + * docking sentinel) anchors TOP only -- layer-shell centers a surface anchored + * to neither horizontal edge -- and promotes the surface to LAYER_TOP so the + * pill floats above normal windows like a system status surface, while + * ordinary widgets sit on LAYER_BOTTOM above the wallpaper. GTK main thread + * only; the caller updates the cached slot x/y itself. */ +static void cn1WidgetLayerPosition(GtkWidget* win, int x, int y) { + GtkWindow* w = GTK_WINDOW(win); + if (y < 0) { + y = 0; + } + if (x < 0) { + p_gtk_layer_set_anchor(w, CN1_LAYER_SHELL_EDGE_LEFT, FALSE); + p_gtk_layer_set_anchor(w, CN1_LAYER_SHELL_EDGE_RIGHT, FALSE); + p_gtk_layer_set_margin(w, CN1_LAYER_SHELL_EDGE_LEFT, 0); + p_gtk_layer_set_layer(w, CN1_LAYER_SHELL_LAYER_TOP); + } else { + p_gtk_layer_set_anchor(w, CN1_LAYER_SHELL_EDGE_RIGHT, FALSE); + p_gtk_layer_set_anchor(w, CN1_LAYER_SHELL_EDGE_LEFT, TRUE); + p_gtk_layer_set_margin(w, CN1_LAYER_SHELL_EDGE_LEFT, x); + p_gtk_layer_set_layer(w, CN1_LAYER_SHELL_LAYER_BOTTOM); + } + p_gtk_layer_set_anchor(w, CN1_LAYER_SHELL_EDGE_TOP, TRUE); + p_gtk_layer_set_margin(w, CN1_LAYER_SHELL_EDGE_TOP, y); +} + +/* --------------------------------------------------------- GTK callbacks */ + +static gboolean cn1WidgetOnDraw(GtkWidget* widget, cairo_t* cr, gpointer data) { + CN1WidgetSlot* s = (CN1WidgetSlot*) data; + (void) widget; + /* Clear to fully transparent first (SOURCE, not OVER) so the alpha baked + * into the rasterized pixels defines the window's translucent shape. */ + cairo_set_operator(cr, CAIRO_OPERATOR_SOURCE); + cairo_set_source_rgba(cr, 0, 0, 0, 0); + cairo_paint(cr); + cairo_set_operator(cr, CAIRO_OPERATOR_OVER); + if (s->surface && s->imgW > 0 && s->imgH > 0) { + /* The bridge rasterizes at 2x for crispness; scale down to the window. */ + cairo_scale(cr, (double) s->winW / s->imgW, (double) s->winH / s->imgH); + cairo_set_source_surface(cr, s->surface, 0, 0); + cairo_pattern_set_filter(cairo_get_source(cr), CAIRO_FILTER_GOOD); + cairo_paint(cr); + } + return FALSE; +} + +static gboolean cn1WidgetOnButton(GtkWidget* widget, GdkEventButton* e, gpointer data) { + CN1WidgetSlot* s = (CN1WidgetSlot*) data; + int x, y, i, hit = 0; + gint64 id; + if (e->type != GDK_BUTTON_PRESS) { + return FALSE; + } + x = (int) e->x; + y = (int) e->y; + pthread_mutex_lock(&cn1WidgetLock); + id = s->id; + for (i = 0; i < s->hitRectCount; i++) { + int rx = s->hitRects[i * 4]; + int ry = s->hitRects[i * 4 + 1]; + int rw = s->hitRects[i * 4 + 2]; + int rh = s->hitRects[i * 4 + 3]; + if (x >= rx && x < rx + rw && y >= ry && y < ry + rh) { + hit = 1; + break; + } + } + pthread_mutex_unlock(&cn1WidgetLock); + if (hit) { + cn1WidgetPushEvent(id, "click", x, y); + } else if (s->layered) { + /* Layer-shell surfaces cannot be moved by gtk_window_begin_move_drag + * (the compositor does not treat them as movable toplevels): run a + * manual drag off the implicit pointer grab instead. Motion deltas + * from the press point update the margins live; releasing pushes the + * final "moved" event for persistence. */ + s->dragging = 1; + s->dragPressX = e->x; + s->dragPressY = e->y; + } else { + /* Anywhere outside an action rectangle drags the applet. On plain + * Wayland (no layer-shell) this is the ONE positioning primitive that + * still works (the compositor drives the move). */ + gtk_window_begin_move_drag(GTK_WINDOW(widget), (gint) e->button, + (gint) e->x_root, (gint) e->y_root, e->time); + } + return TRUE; +} + +static gboolean cn1WidgetOnMotion(GtkWidget* widget, GdkEventMotion* e, gpointer data) { + CN1WidgetSlot* s = (CN1WidgetSlot*) data; + int dx, dy, nx, ny; + gint64 id, now; + (void) widget; + if (!s->dragging || !s->window) { + return FALSE; + } + /* Window-relative delta from the press point: applying it to the margins + * moves the surface under the pointer, which drives the next event's + * window coordinates back toward the press point (the classic manual-drag + * feedback loop, needed because Wayland exposes no global pointer). */ + dx = (int) (e->x - s->dragPressX); + dy = (int) (e->y - s->dragPressY); + if (dx == 0 && dy == 0) { + return TRUE; + } + pthread_mutex_lock(&cn1WidgetLock); + id = s->id; + nx = s->x; + ny = s->y; + pthread_mutex_unlock(&cn1WidgetLock); + if (nx >= 0) { + /* A centered surface (x == -1, the pill) stays centered: only the + * vertical margin follows the drag. */ + nx += dx; + if (nx < 0) { + nx = 0; + } + } + ny += dy; + if (ny < 0) { + ny = 0; + } + cn1WidgetLayerPosition(s->window, nx, ny); + pthread_mutex_lock(&cn1WidgetLock); + s->x = nx; + s->y = ny; + pthread_mutex_unlock(&cn1WidgetLock); + now = g_get_monotonic_time(); + if (now - s->lastMovePush >= CN1_WIDGET_MOVE_THROTTLE_US) { + s->lastMovePush = now; + cn1WidgetPushEvent(id, "moved", nx, ny); + } + return TRUE; +} + +static gboolean cn1WidgetOnRelease(GtkWidget* widget, GdkEventButton* e, gpointer data) { + CN1WidgetSlot* s = (CN1WidgetSlot*) data; + gint64 id; + int x, y; + (void) widget; + (void) e; + if (!s->dragging) { + return FALSE; + } + s->dragging = 0; + pthread_mutex_lock(&cn1WidgetLock); + id = s->id; + x = s->x; + y = s->y; + pthread_mutex_unlock(&cn1WidgetLock); + /* Unthrottled: the drop position is what the Java side persists. */ + s->lastMovePush = g_get_monotonic_time(); + cn1WidgetPushEvent(id, "moved", x, y); + return TRUE; +} + +static gboolean cn1WidgetOnConfigure(GtkWidget* widget, GdkEventConfigure* e, gpointer data) { + CN1WidgetSlot* s = (CN1WidgetSlot*) data; + gint64 id = 0; + int movedNow = 0; + (void) widget; + if (s->layered) { + /* Wayland configure-events carry no meaningful position; the margins + * cached by cn1WidgetLayerPosition are the authoritative geometry. */ + return FALSE; + } + pthread_mutex_lock(&cn1WidgetLock); + if (s->x != e->x || s->y != e->y) { + s->x = e->x; + s->y = e->y; + id = s->id; + movedNow = 1; + } + pthread_mutex_unlock(&cn1WidgetLock); + if (movedNow) { + /* Throttled; the cached x/y above is always current for widgetGetX/Y. */ + gint64 now = g_get_monotonic_time(); + if (now - s->lastMovePush >= CN1_WIDGET_MOVE_THROTTLE_US) { + s->lastMovePush = now; + cn1WidgetPushEvent(id, "moved", e->x, e->y); + } + } + return FALSE; +} + +static gboolean cn1WidgetOnDelete(GtkWidget* widget, GdkEvent* e, gpointer data) { + (void) widget; + (void) e; + (void) data; + /* The bridge owns the lifecycle; ignore WM close attempts. */ + return TRUE; +} + +/* --------------------------------------------------------------- create */ + +typedef struct { + gint64 id; +} CN1WidgetIdOp; + +static gboolean cn1WidgetCreateOnMain(gpointer data) { + CN1WidgetIdOp* op = (CN1WidgetIdOp*) data; + CN1WidgetSlot* s; + GtkWidget* win; + GdkScreen* screen; + GdkVisual* rgba; + int defX, defY, index; + pthread_mutex_lock(&cn1WidgetLock); + s = cn1WidgetById(op->id); + pthread_mutex_unlock(&cn1WidgetLock); + if (!s) { + free(op); + return FALSE; + } + win = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_window_set_decorated(GTK_WINDOW(win), FALSE); + gtk_window_set_type_hint(GTK_WINDOW(win), GDK_WINDOW_TYPE_HINT_UTILITY); + gtk_window_set_keep_above(GTK_WINDOW(win), TRUE); + gtk_window_stick(GTK_WINDOW(win)); + gtk_window_set_skip_taskbar_hint(GTK_WINDOW(win), TRUE); + gtk_window_set_skip_pager_hint(GTK_WINDOW(win), TRUE); + /* An applet should never steal keyboard focus from the app; action clicks + * explicitly present the main window instead (widgetFocusApp). */ + gtk_window_set_accept_focus(GTK_WINDOW(win), FALSE); + gtk_window_set_resizable(GTK_WINDOW(win), FALSE); + gtk_widget_set_app_paintable(win, TRUE); + screen = gtk_widget_get_screen(win); + rgba = screen ? gdk_screen_get_rgba_visual(screen) : 0; + if (rgba) { + /* Translucent rounded corners need the compositor's ARGB visual. */ + gtk_widget_set_visual(win, rgba); + } + gtk_window_set_default_size(GTK_WINDOW(win), s->winW, s->winH); + gtk_widget_set_events(win, gtk_widget_get_events(win) + | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK + | GDK_BUTTON_MOTION_MASK | GDK_STRUCTURE_MASK); + g_signal_connect(win, "draw", G_CALLBACK(cn1WidgetOnDraw), s); + g_signal_connect(win, "button-press-event", G_CALLBACK(cn1WidgetOnButton), s); + g_signal_connect(win, "motion-notify-event", G_CALLBACK(cn1WidgetOnMotion), s); + g_signal_connect(win, "button-release-event", G_CALLBACK(cn1WidgetOnRelease), s); + g_signal_connect(win, "configure-event", G_CALLBACK(cn1WidgetOnConfigure), s); + g_signal_connect(win, "delete-event", G_CALLBACK(cn1WidgetOnDelete), s); + /* Default placement near the top-right, cascading by slot so several + * widgets do not stack exactly; the bridge overrides with the persisted + * position right after create (idles run in order). */ + index = (int) (s - cn1Widgets); + defX = 100; + if (screen) { + int sw = gdk_screen_get_width(screen); + if (sw > s->winW + 40) { + defX = sw - s->winW - 40; + } + } + defY = 60 + (index % 8) * 40; + if (cn1LoadLayerShell()) { + /* MUST run before the window is realized/shown: gtk-layer-shell hooks + * the surface creation. The EWMH hints set above become harmless + * no-ops on the layer surface. */ + p_gtk_layer_init_for_window(GTK_WINDOW(win)); + p_gtk_layer_set_keyboard_mode(GTK_WINDOW(win), CN1_LAYER_SHELL_KEYBOARD_MODE_NONE); + p_gtk_layer_set_exclusive_zone(GTK_WINDOW(win), 0); + cn1WidgetLayerPosition(win, defX, defY); + s->layered = 1; + } else { + /* Silent no-op on Wayland without layer-shell (see the file header). */ + gtk_window_move(GTK_WINDOW(win), defX, defY); + } + pthread_mutex_lock(&cn1WidgetLock); + s->window = win; + s->x = defX; + s->y = defY; + pthread_mutex_unlock(&cn1WidgetLock); + gtk_widget_show_all(win); + free(op); + return FALSE; +} + +JAVA_LONG com_codename1_impl_linux_LinuxNative_widgetCreate___int_int_R_long(CODENAME_ONE_THREAD_STATE, JAVA_INT w, JAVA_INT h) { + CN1WidgetSlot* s = 0; + CN1WidgetIdOp* op; + gint64 id; + int i; + if (cn1LinuxWindowWidget() == 0) { + /* Headless / no GTK loop: no surface to materialize on. */ + return 0; + } + pthread_mutex_lock(&cn1WidgetLock); + for (i = 0; i < CN1_WIDGET_SLOTS; i++) { + if (cn1Widgets[i].id == 0) { + s = &cn1Widgets[i]; + break; + } + } + if (!s) { + pthread_mutex_unlock(&cn1WidgetLock); + return 0; + } + memset(s, 0, sizeof(CN1WidgetSlot)); + id = cn1WidgetNextId++; + s->id = id; + s->winW = w > 0 ? w : 1; + s->winH = h > 0 ? h : 1; + pthread_mutex_unlock(&cn1WidgetLock); + op = (CN1WidgetIdOp*) malloc(sizeof(CN1WidgetIdOp)); + op->id = id; + gdk_threads_add_idle(cn1WidgetCreateOnMain, op); + return (JAVA_LONG) id; +} + +/* --------------------------------------------------------------- pixels */ + +typedef struct { + gint64 id; + uint32_t* px; /* premultiplied ARGB, tightly packed w*h */ + int w, h; +} CN1WidgetPixelsOp; + +/* Straight CN1 ARGB -> premultiplied cairo ARGB32 (both are logical uint32 + * values with A in the top byte, so whole-word stores are endian-correct). */ +static uint32_t cn1WidgetPremul(uint32_t p) { + uint32_t a = p >> 24; + uint32_t r, g, b; + if (a == 255) { + return p; + } + if (a == 0) { + return 0; + } + r = (((p >> 16) & 0xff) * a + 127) / 255; + g = (((p >> 8) & 0xff) * a + 127) / 255; + b = ((p & 0xff) * a + 127) / 255; + return (a << 24) | (r << 16) | (g << 8) | b; +} + +static gboolean cn1WidgetPixelsOnMain(gpointer data) { + CN1WidgetPixelsOp* op = (CN1WidgetPixelsOp*) data; + CN1WidgetSlot* s; + cairo_surface_t* surface; + unsigned char* dst; + int stride, row; + pthread_mutex_lock(&cn1WidgetLock); + s = cn1WidgetById(op->id); + pthread_mutex_unlock(&cn1WidgetLock); + if (!s || !s->window) { + free(op->px); + free(op); + return FALSE; + } + surface = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, op->w, op->h); + if (cairo_surface_status(surface) == CAIRO_STATUS_SUCCESS) { + cairo_surface_flush(surface); + dst = cairo_image_surface_get_data(surface); + stride = cairo_image_surface_get_stride(surface); + for (row = 0; row < op->h; row++) { + memcpy(dst + (size_t) row * stride, op->px + (size_t) row * op->w, + (size_t) op->w * 4); + } + cairo_surface_mark_dirty(surface); + if (s->surface) { + cairo_surface_destroy(s->surface); + } + s->surface = surface; + s->imgW = op->w; + s->imgH = op->h; + gtk_widget_queue_draw(s->window); + } else { + cairo_surface_destroy(surface); + } + free(op->px); + free(op); + return FALSE; +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_widgetUpdatePixels___long_int_1ARRAY_int_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_OBJECT argb, JAVA_INT w, JAVA_INT h) { + CN1WidgetPixelsOp* op; + JAVA_INT* src; + int64_t count, i; + if (peer == 0 || argb == JAVA_NULL || w <= 0 || h <= 0) { + return; + } + count = (int64_t) w * h; + if ((int64_t) (*(JAVA_ARRAY) argb).length < count) { + return; + } + src = (JAVA_INT*) (*(JAVA_ARRAY) argb).data; + op = (CN1WidgetPixelsOp*) malloc(sizeof(CN1WidgetPixelsOp)); + op->id = (gint64) peer; + op->w = w; + op->h = h; + op->px = (uint32_t*) malloc((size_t) count * 4); + for (i = 0; i < count; i++) { + op->px[i] = cn1WidgetPremul((uint32_t) src[i]); + } + gdk_threads_add_idle(cn1WidgetPixelsOnMain, op); +} + +/* -------------------------------------------------------------- position */ + +typedef struct { + gint64 id; + int x, y; +} CN1WidgetMoveOp; + +static gboolean cn1WidgetMoveOnMain(gpointer data) { + CN1WidgetMoveOp* op = (CN1WidgetMoveOp*) data; + CN1WidgetSlot* s; + int x = op->x; + pthread_mutex_lock(&cn1WidgetLock); + s = cn1WidgetById(op->id); + pthread_mutex_unlock(&cn1WidgetLock); + if (s && s->window) { + if (s->layered) { + /* Anchors + margins; x stays -1 for a centered (pill) surface so + * widgetGetX round-trips the centering sentinel. Clamp y here too + * so the cached value matches the applied margin. */ + if (op->y < 0) { + op->y = 0; + } + cn1WidgetLayerPosition(s->window, x, op->y); + } else { + if (x < 0) { + /* x == -1 centers horizontally (the live-activity pill docks + * top-center without the Java side needing a screen-size + * native). */ + GdkScreen* screen = gtk_widget_get_screen(s->window); + int sw = screen ? gdk_screen_get_width(screen) : 0; + x = sw > s->winW ? (sw - s->winW) / 2 : 0; + } + /* Silent no-op under Wayland (see the file header). */ + gtk_window_move(GTK_WINDOW(s->window), x, op->y); + } + pthread_mutex_lock(&cn1WidgetLock); + s->x = x; + s->y = op->y; + pthread_mutex_unlock(&cn1WidgetLock); + } + free(op); + return FALSE; +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_widgetSetPosition___long_int_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_INT x, JAVA_INT y) { + CN1WidgetMoveOp* op; + if (peer == 0) { + return; + } + op = (CN1WidgetMoveOp*) malloc(sizeof(CN1WidgetMoveOp)); + op->id = (gint64) peer; + op->x = x; + op->y = y; + gdk_threads_add_idle(cn1WidgetMoveOnMain, op); +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_widgetGetX___long_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer) { + CN1WidgetSlot* s; + int x = 0; + pthread_mutex_lock(&cn1WidgetLock); + s = cn1WidgetById((gint64) peer); + if (s) { + x = s->x; + } + pthread_mutex_unlock(&cn1WidgetLock); + return x; +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_widgetGetY___long_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer) { + CN1WidgetSlot* s; + int y = 0; + pthread_mutex_lock(&cn1WidgetLock); + s = cn1WidgetById((gint64) peer); + if (s) { + y = s->y; + } + pthread_mutex_unlock(&cn1WidgetLock); + return y; +} + +/* -------------------------------------------------------------- hit rects */ + +JAVA_VOID com_codename1_impl_linux_LinuxNative_widgetSetHitRects___long_int_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_OBJECT rects) { + /* Plain shared data (no GTK), so a mutex-guarded swap suffices -- the + * button-press handler reads it under the same lock. */ + CN1WidgetSlot* s; + int* copy = 0; + int count = 0; + if (rects != JAVA_NULL) { + int len = (int) (*(JAVA_ARRAY) rects).length; + count = len / 4; + if (count > 0) { + JAVA_INT* src = (JAVA_INT*) (*(JAVA_ARRAY) rects).data; + int i; + copy = (int*) malloc((size_t) count * 4 * sizeof(int)); + for (i = 0; i < count * 4; i++) { + copy[i] = src[i]; + } + } + } + pthread_mutex_lock(&cn1WidgetLock); + s = cn1WidgetById((gint64) peer); + if (s) { + free(s->hitRects); + s->hitRects = copy; + s->hitRectCount = count; + copy = 0; + } + pthread_mutex_unlock(&cn1WidgetLock); + free(copy); /* slot vanished before the swap */ +} + +/* ---------------------------------------------------------------- destroy */ + +static gboolean cn1WidgetDestroyOnMain(gpointer data) { + CN1WidgetIdOp* op = (CN1WidgetIdOp*) data; + CN1WidgetSlot* s; + pthread_mutex_lock(&cn1WidgetLock); + s = cn1WidgetById(op->id); + pthread_mutex_unlock(&cn1WidgetLock); + if (s) { + /* We ARE the GTK thread: destroying the window here runs its handlers + * synchronously, so the surface/slot teardown below cannot race them. */ + if (s->window) { + gtk_widget_destroy(s->window); + s->window = 0; + } + if (s->surface) { + cairo_surface_destroy(s->surface); + s->surface = 0; + } + pthread_mutex_lock(&cn1WidgetLock); + free(s->hitRects); + s->hitRects = 0; + s->hitRectCount = 0; + s->id = 0; /* only now may the EDT recycle the slot */ + pthread_mutex_unlock(&cn1WidgetLock); + } + free(op); + return FALSE; +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_widgetDestroy___long(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer) { + CN1WidgetIdOp* op; + if (peer == 0) { + return; + } + op = (CN1WidgetIdOp*) malloc(sizeof(CN1WidgetIdOp)); + op->id = (gint64) peer; + gdk_threads_add_idle(cn1WidgetDestroyOnMain, op); +} + +/* ------------------------------------------------------------ focus / poll */ + +static gboolean cn1WidgetFocusAppOnMain(gpointer data) { + GtkWidget* main = cn1LinuxWindowWidget(); + (void) data; + if (main) { + gtk_window_present(GTK_WINDOW(main)); + } + return FALSE; +} + +/* Presents (raises + focuses) the main app window; used when a widget action + * is clicked so the handler surfaces in a visible app. */ +void cn1LinuxWidgetFocusApp(void) { + gdk_threads_add_idle(cn1WidgetFocusAppOnMain, 0); +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_widgetFocusApp__(CODENAME_ONE_THREAD_STATE) { + cn1LinuxWidgetFocusApp(); +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_widgetPollEvent___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { + char* msg = cn1LinuxWidgetPollEvent(); + JAVA_OBJECT result; + if (!msg) { + return JAVA_NULL; + } + result = newStringFromCString(threadStateData, msg); + g_free(msg); + return result; +} diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 0ad7ab4eda3..4fd8f9f31ee 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -691,6 +691,32 @@ private void drainInput() { if (clicked != null) { dispatchLocalNotification(clicked); } + // Applet widget windows queue their click / moved events on the same + // native pump; the bridge routes clicks through Surfaces.dispatchAction. + LinuxWidgetBridge bridge = widgetBridge; + if (bridge != null) { + bridge.drainNativeEvents(); + } + } + + /* ------------------------------------------------------------ surfaces */ + + /** + * The bridge behind {@code com.codename1.surfaces} on this port: pinned widget + * kinds render as frameless always-on-top GTK "applet" windows and a live + * activity docks a pill window top-center (full behavior on X11/XWayland; + * plain floating windows under Wayland -- see cn1_linux_widgets.c). Volatile + * because it is created lazily on the EDT and read by the main pump thread's + * {@link #drainInput()}. + */ + private volatile LinuxWidgetBridge widgetBridge; + + @Override + public com.codename1.surfaces.spi.SurfaceBridge getSurfaceBridge() { + if (widgetBridge == null) { + widgetBridge = new LinuxWidgetBridge(); + } + return widgetBridge; } /* --------------------------------------------------- local notifications diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index dd4d719d858..b0b4e149f36 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -503,6 +503,60 @@ public static native long editStringAt(int x, int y, int w, int h, String text, */ public static native String notificationPollClicked(); + /* -------------------------------------------------- surfaces widgets */ + + /** + * Creates a frameless GTK "applet" window of the given logical size for a + * pinned surfaces widget (or the live-activity pill): undecorated, utility + * type hint, keep-above, sticky, skip-taskbar/pager, RGBA visual for + * translucent rounded corners. Returns an opaque nonzero id immediately + * (the window materializes asynchronously on the GTK main loop), or 0 in + * headless mode / when the slot table is full. Under Wayland positioning + * and keep-above silently degrade; see cn1_linux_widgets.c. + */ + public static native long widgetCreate(int w, int h); + + /** + * Replaces the widget's pixels with a {@code w*h} block of straight-alpha + * CN1 ARGB (premultiplied while converting to the cairo backing store). + * The pixel buffer may be larger than the window (e.g. 2x for crispness); + * the draw handler scales it to the window size. + */ + public static native void widgetUpdatePixels(long peer, int[] argb, int w, int h); + + /** + * Moves the widget window to screen coordinates. {@code x == -1} centers + * it horizontally (used to dock the live-activity pill top-center). + * Silently ignored by Wayland compositors. + */ + public static native void widgetSetPosition(long peer, int x, int y); + + /** The widget window's last known screen x (cached from configure events). */ + public static native int widgetGetX(long peer); + + /** The widget window's last known screen y (cached from configure events). */ + public static native int widgetGetY(long peer); + + /** + * Sets the action hit rectangles as {@code x,y,w,h} quads in window + * coordinates: a button press inside any rect queues a {@code click} + * event; a press outside starts an interactive window drag. + */ + public static native void widgetSetHitRects(long peer, int[] rects); + + /** Destroys the widget window and frees its slot (async, on the GTK loop). */ + public static native void widgetDestroy(long peer); + + /** + * Next queued widget event ({@code ";click;;"} or + * {@code ";moved;;"}, window coordinates), or {@code null} when + * none. Drained by the main-thread input pump alongside {@link #pollEvent}. + */ + public static native String widgetPollEvent(); + + /** Presents (raises and focuses) the main app window after an action click. */ + public static native void widgetFocusApp(); + /** * Shows a modal native file dialog and returns the chosen path, or * {@code null} on cancel (or in headless mode). {@code type} mirrors the diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWidgetBridge.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWidgetBridge.java new file mode 100644 index 00000000000..82bfbd66ef5 --- /dev/null +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWidgetBridge.java @@ -0,0 +1,1041 @@ +/* + * 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.linux; + +import com.codename1.io.JSONParser; +import com.codename1.io.Preferences; +import com.codename1.surfaces.SurfaceRasterizer; +import com.codename1.surfaces.Surfaces; +import com.codename1.surfaces.spi.SurfaceBridge; +import com.codename1.ui.Display; + +import java.io.ByteArrayOutputStream; +import java.io.StringReader; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.Timer; +import java.util.TimerTask; + +/** + * The Linux {@link SurfaceBridge}: every pinned widget kind gets a frameless GTK "applet" + * window (undecorated, keep-above, sticky, skip-taskbar, translucent rounded corners) that + * renders the kind's published timeline through the shared {@link SurfaceRasterizer} -- the + * desktop equivalent of a home-screen widget. A running live activity docks a black pill + * window (~250x36 dips) at the top-center of the screen. + * + *

Everything published through this bridge is kept in memory for rendering and + * persisted under {@code /cn1surfaces//} (atomic write-rename) so + * timelines survive process death per the surfaces SPI contract; the pinned set and window + * geometry persist through {@link Preferences} ({@code cn1.surfaces.*} keys) so widgets + * restore where the user left them on the next run. Live activities are transient.

+ * + *

Threading: rasterization always runs on the Codename One EDT ({@link SurfaceRasterizer} + * requirement); the {@code LinuxNative.widget*} calls are thread-agnostic (they marshal onto + * the GTK main loop internally). Native window events arrive on the main pump thread via + * {@link #drainNativeEvents()} (called from {@code LinuxImplementation.drainInput}); action + * clicks are routed through {@code Surfaces.dispatchAction}, which does its own EDT + * marshaling and cold-start queuing. Refresh timers (dynamic-text ticks, timeline entry + * flips) run on a shared {@link Timer} and bounce back to the EDT.

+ * + *

There is no tray UI in this port, so {@link #pinWidget} / {@link #unpinWidget} are the + * programmatic pinning entry points (reachable via + * {@code (LinuxWidgetBridge) Display.getInstance().getSurfaceBridge()}); previously pinned + * widgets restore automatically when the bridge is created. Under Wayland the applet windows + * degrade to plain floating windows (no global positioning / keep-above -- see + * {@code cn1_linux_widgets.c}).

+ */ +public class LinuxWidgetBridge implements SurfaceBridge { + /** Rasterize at 2x the dip size for crisp output; the window scales it back down. */ + private static final int SCALE = 2; + private static final int CORNER = 24; + private static final String[] SIZE_NAMES = {"small", "medium", "large"}; + private static final int[] SIZE_W = {158, 338, 338}; + private static final int[] SIZE_H = {158, 158, 354}; + private static final int PILL_W = 250; + private static final int PILL_H = 36; + private static final int PILL_TOP = 8; + private static final int MIN_REFRESH_DELAY = 50; + private static final int PERSIST_DEBOUNCE = 500; + private static final int END_LINGER = 3000; + + private static final String PREF_PINNED = "cn1.surfaces.pinned"; + private static final String PREF_GEOM_PREFIX = "cn1.surfaces.geom."; + + private final String root; + private final Map> kinds = + new LinkedHashMap>(); + private final Map> timelines = + new HashMap>(); + private final Map> kindImages = + new HashMap>(); + private final Map windows = + new LinkedHashMap(); + private final Timer timer = new Timer(); + private TimerTask persistTask; + private PillWindow pill; + private int nextActivityId = 1; + + /** + * Creates the bridge, restores timelines persisted by a previous run and re-pins the + * widgets the user had on screen. + */ + public LinuxWidgetBridge() { + String dir = LinuxNative.storageDir(); + if (dir == null || dir.length() == 0) { + dir = "/tmp"; + } + if (dir.endsWith("/")) { + dir = dir.substring(0, dir.length() - 1); + } + root = dir + "/cn1surfaces"; + LinuxNative.fileMkdir(root); + restoreFromDisk(); + restorePinned(); + } + + /* --------------------------------------------------------- SurfaceBridge */ + + @Override + public boolean areWidgetsSupported() { + return true; + } + + @Override + public boolean isLiveActivitySupported() { + return true; + } + + @Override + public void registerWidgetKind(String kindJson) { + Map kind = parse(kindJson); + if (kind == null || !(kind.get("id") instanceof String)) { + return; + } + String kindId = (String) kind.get("id"); + synchronized (this) { + kinds.put(kindId, kind); + } + writeFileSafely(kindDir(kindId) + "/kind.json", utf8(kindJson)); + } + + @Override + public void publishWidgetTimeline(String kindId, String timelineJson, + Map images) { + Map doc = parse(timelineJson); + if (doc == null || kindId == null) { + return; + } + String dir = kindDir(kindId); + // The document's images list is the COMPLETE reference set (the serializer includes + // registered-name references to blobs shipped earlier). Resolve every referenced blob + // (new bytes, previous in-memory map, disk) so registered-name references keep + // rendering, and drop everything unreferenced. + Set referenced = referencedImageNames(doc); + Map imageCopy = new HashMap(); + synchronized (this) { + Map previous = kindImages.get(kindId); + for (String name : referenced) { + byte[] data = images != null ? images.get(name) : null; + if (data == null && previous != null) { + data = previous.get(name); + } + if (data == null) { + data = readFile(dir + "/" + name + ".png"); + } + if (data != null) { + imageCopy.put(name, data); + } + } + timelines.put(kindId, doc); + kindImages.put(kindId, imageCopy); + } + writeFileSafely(dir + "/timeline.json", utf8(timelineJson)); + for (Map.Entry e : imageCopy.entrySet()) { + String png = dir + "/" + e.getKey() + ".png"; + if (!LinuxNative.fileExists(png)) { + // image names are content hashes so an existing file never needs rewriting + writeFileSafely(png, e.getValue()); + } + } + // garbage collect blobs the replacement timeline no longer references: content-hash + // names would otherwise accumulate without bound for frequently changing art + String[] files = LinuxNative.fileList(dir); + if (files != null) { + for (String name : files) { + if (name != null && name.endsWith(".png") + && !referenced.contains(name.substring(0, name.length() - 4))) { + LinuxNative.fileDelete(dir + "/" + name); + } + } + } + WidgetWindow w; + synchronized (this) { + w = windows.get(kindId); + } + if (w != null) { + w.requestRender(); + } + } + + /// The names in the parsed timeline document's `images` list. + private static Set referencedImageNames(Map doc) { + Set referenced = new HashSet(); + Object names = doc.get("images"); + if (names instanceof java.util.Collection) { + for (Object o : (java.util.Collection) names) { + referenced.add(String.valueOf(o)); + } + } + return referenced; + } + + @Override + public void reloadWidgets(String kindId) { + List all; + synchronized (this) { + all = new ArrayList(windows.values()); + } + for (WidgetWindow w : all) { + if (kindId == null || kindId.equals(w.kindId)) { + w.requestRender(); + } + } + } + + @Override + public synchronized int getInstalledWidgetCount(String kindId) { + return windows.containsKey(kindId) ? 1 : 0; + } + + @Override + public String startLiveActivity(String descriptorJson, Map images) { + Map doc = parse(descriptorJson); + if (doc == null) { + return null; + } + Map imageCopy = new HashMap(); + if (images != null) { + imageCopy.putAll(images); + } + LiveActivityRecord rec = new LiveActivityRecord(doc, imageCopy, asMap(doc.get("state"))); + String id; + PillWindow old; + PillWindow created; + synchronized (this) { + id = "la" + nextActivityId; + nextActivityId++; + rec.id = id; + old = pill; + created = new PillWindow(rec); + pill = created; + } + if (old != null) { + old.close(); + } + created.requestRender(); + return id; + } + + @Override + public void updateLiveActivity(String activityId, String stateJson) { + PillWindow p; + synchronized (this) { + p = pill; + } + if (p == null || !p.record.id.equals(activityId) || p.record.ended) { + return; + } + Map state = parse(stateJson); + p.record.state = state == null ? new HashMap() : state; + p.requestRender(); + } + + @Override + public void endLiveActivity(String activityId, String finalStateJson, + boolean dismissImmediately) { + final PillWindow p; + synchronized (this) { + p = pill; + if (p == null || !p.record.id.equals(activityId) || p.record.ended) { + return; + } + p.record.ended = true; + pill = null; + } + if (finalStateJson != null) { + Map state = parse(finalStateJson); + if (state != null) { + p.record.state = state; + } + } + if (dismissImmediately) { + p.close(); + return; + } + // show the final state briefly before dismissing, like the mobile platforms do + p.requestRender(); + timer.schedule(new TimerTask() { + @Override + public void run() { + p.close(); + } + }, END_LINGER); + } + + /* ------------------------------------------------------------- pinning */ + + /** + * Pins a floating applet window for a widget kind (one instance per kind), or changes + * the size of an already-pinned one. Any thread. + * + * @param kindId the widget kind to pin + * @param sizeName {@code small} / {@code medium} / {@code large}, null for small + */ + public void pinWidget(String kindId, String sizeName) { + if (kindId == null) { + return; + } + WidgetWindow w; + WidgetWindow created = null; + synchronized (this) { + w = windows.get(kindId); + if (w == null) { + created = new WidgetWindow(kindId, normalizeSize(sizeName), + loadGeometry(kindId)); + windows.put(kindId, created); + } + } + if (created != null) { + created.requestRender(); + } else if (sizeName != null) { + w.setSizeName(normalizeSize(sizeName)); + } + schedulePersist(); + } + + /** + * Removes a pinned applet window. Any thread. + * + * @param kindId the widget kind to unpin + */ + public void unpinWidget(String kindId) { + WidgetWindow w; + synchronized (this) { + w = windows.remove(kindId); + } + if (w != null) { + w.close(); + Preferences.delete(PREF_GEOM_PREFIX + w.kindId); + } + schedulePersist(); + } + + private void restorePinned() { + String pinned = Preferences.get(PREF_PINNED, ""); + if (pinned.length() == 0) { + return; + } + for (String entry : split(pinned, ';')) { + int sep = entry.indexOf('|'); + if (sep <= 0) { + continue; + } + pinWidget(entry.substring(0, sep), entry.substring(sep + 1)); + } + } + + /** Persisted "x,y" screen position of a kind's window, or null. */ + private static int[] loadGeometry(String kindId) { + String geom = Preferences.get(PREF_GEOM_PREFIX + kindId, null); + if (geom == null) { + return null; + } + int comma = geom.indexOf(','); + if (comma <= 0) { + return null; + } + try { + return new int[] {Integer.parseInt(geom.substring(0, comma)), + Integer.parseInt(geom.substring(comma + 1))}; + } catch (NumberFormatException err) { + return null; + } + } + + /** Debounced persistence of the pinned set + geometry (pattern: JavaSE widgets). */ + private synchronized void schedulePersist() { + if (persistTask != null) { + persistTask.cancel(); + } + persistTask = new TimerTask() { + @Override + public void run() { + persistPinned(); + } + }; + timer.schedule(persistTask, PERSIST_DEBOUNCE); + } + + private void persistPinned() { + List all; + synchronized (this) { + all = new ArrayList(windows.values()); + } + StringBuilder sb = new StringBuilder(); + for (WidgetWindow w : all) { + if (sb.length() > 0) { + sb.append(';'); + } + sb.append(w.kindId).append('|').append(w.sizeName); + if (w.peer != 0) { + // the native side caches the position from configure events, so this is + // accurate even when the throttled "moved" stream swallowed the last one + Preferences.set(PREF_GEOM_PREFIX + w.kindId, + LinuxNative.widgetGetX(w.peer) + "," + LinuxNative.widgetGetY(w.peer)); + } + } + Preferences.set(PREF_PINNED, sb.toString()); + } + + /* ----------------------------------------------------- native event drain */ + + /** + * Drains the native widget event queue ({@code ";click;;"} / + * {@code ";moved;;"}). Called from the main pump thread's input drain loop. + */ + void drainNativeEvents() { + String ev = LinuxNative.widgetPollEvent(); + while (ev != null) { + handleNativeEvent(ev); + ev = LinuxNative.widgetPollEvent(); + } + } + + private void handleNativeEvent(String ev) { + String[] parts = split(ev, ';'); + if (parts.length < 4) { + return; + } + long peer; + int x; + int y; + try { + peer = Long.parseLong(parts[0]); + x = Integer.parseInt(parts[2]); + y = Integer.parseInt(parts[3]); + } catch (NumberFormatException err) { + return; + } + String kind = parts[1]; + if ("moved".equals(kind)) { + schedulePersist(); + return; + } + if (!"click".equals(kind)) { + return; + } + SurfaceWindow target = null; + synchronized (this) { + for (WidgetWindow w : windows.values()) { + if (w.peer == peer) { + target = w; + break; + } + } + if (target == null && pill != null && pill.peer == peer) { + target = pill; + } + } + if (target != null) { + target.handleClick(x, y); + } + } + + /* ------------------------------------------------------- surface windows */ + + /** Shared behavior of the applet windows: native peer, hit testing, refresh timing. */ + private abstract class SurfaceWindow { + long peer; + /** Hit rects in rasterized (scaled) pixels; written on the EDT, read on the pump thread. */ + volatile List actions = + new ArrayList(); + TimerTask refreshTask; + + abstract void renderNow(); + + abstract String actionSource(); + + /** Rasterizes on the EDT (SurfaceRasterizer requirement) whatever thread calls. */ + void requestRender() { + if (!Display.isInitialized()) { + return; + } + Display d = Display.getInstance(); + Runnable r = new Runnable() { + @Override + public void run() { + renderNow(); + } + }; + if (d.isEdt()) { + r.run(); + } else { + d.callSerially(r); + } + } + + /** Click in window (dip) coordinates from the native button handler. */ + void handleClick(int x, int y) { + int px = x * SCALE; + int py = y * SCALE; + for (SurfaceRasterizer.ActionRect r : actions) { + if (px >= r.getX() && px < r.getX() + r.getWidth() + && py >= r.getY() && py < r.getY() + r.getHeight()) { + LinuxNative.widgetFocusApp(); + // Surfaces.dispatchAction marshals to the CN1 EDT itself + Surfaces.dispatchAction(actionSource(), r.getActionId(), r.getParams()); + return; + } + } + } + + /** Pushes pixels + hit rects to the native window and re-arms the refresh timer. */ + void showResult(SurfaceRasterizer.Result result, int pixelW, int pixelH, + long nextFlipMillis) { + if (peer == 0) { + return; + } + LinuxNative.widgetUpdatePixels(peer, result.getArgb(), pixelW, pixelH); + actions = result.getActions(); + List rects = result.getActions(); + int[] flat = new int[rects.size() * 4]; + for (int i = 0; i < rects.size(); i++) { + SurfaceRasterizer.ActionRect r = rects.get(i); + // native hit tests run in window (dip) coordinates + flat[i * 4] = r.getX() / SCALE; + flat[i * 4 + 1] = r.getY() / SCALE; + flat[i * 4 + 2] = r.getWidth() / SCALE; + flat[i * 4 + 3] = r.getHeight() / SCALE; + } + LinuxNative.widgetSetHitRects(peer, flat); + long due = result.getNextTickMillis(); + if (nextFlipMillis > 0 && (due == 0 || nextFlipMillis < due)) { + due = nextFlipMillis; + } + scheduleRefresh(due); + } + + void scheduleRefresh(long dueMillis) { + if (refreshTask != null) { + refreshTask.cancel(); + refreshTask = null; + } + if (dueMillis <= 0 || peer == 0) { + return; + } + refreshTask = new TimerTask() { + @Override + public void run() { + requestRender(); + } + }; + timer.schedule(refreshTask, + Math.max(MIN_REFRESH_DELAY, dueMillis - System.currentTimeMillis())); + } + + void close() { + if (refreshTask != null) { + refreshTask.cancel(); + refreshTask = null; + } + if (peer != 0) { + LinuxNative.widgetDestroy(peer); + peer = 0; + } + } + } + + /** A pinned applet window rendering one kind's published timeline. */ + private final class WidgetWindow extends SurfaceWindow { + final String kindId; + String sizeName; + + WidgetWindow(String kindId, String sizeName, int[] location) { + this.kindId = kindId; + this.sizeName = sizeName; + int index = sizeIndex(sizeName); + peer = LinuxNative.widgetCreate(SIZE_W[index], SIZE_H[index]); + if (peer != 0 && location != null) { + LinuxNative.widgetSetPosition(peer, location[0], location[1]); + } + } + + /** Changing the size recreates the native window (its size is fixed at create). */ + void setSizeName(String newSize) { + if (newSize.equals(sizeName)) { + return; + } + sizeName = newSize; + long old = peer; + int x = 0; + int y = 0; + if (old != 0) { + x = LinuxNative.widgetGetX(old); + y = LinuxNative.widgetGetY(old); + } + int index = sizeIndex(newSize); + peer = LinuxNative.widgetCreate(SIZE_W[index], SIZE_H[index]); + if (peer != 0 && old != 0) { + LinuxNative.widgetSetPosition(peer, x, y); + } + if (old != 0) { + LinuxNative.widgetDestroy(old); + } + requestRender(); + schedulePersist(); + } + + @Override + void renderNow() { + if (peer == 0) { + return; + } + Map doc; + Map images; + String displayName; + synchronized (LinuxWidgetBridge.this) { + doc = timelines.get(kindId); + Map imgs = kindImages.get(kindId); + images = imgs == null ? new HashMap() : imgs; + Map kind = kinds.get(kindId); + Object name = kind == null ? null : kind.get("name"); + displayName = name instanceof String ? (String) name : kindId; + } + long now = System.currentTimeMillis(); + Map layout = SurfaceRasterizer.layoutForSize(doc, sizeName); + Map state; + if (layout == null) { + // nothing published yet: show the kind name on the widget background + layout = placeholderNode(displayName); + state = new HashMap(); + } else { + Map entry = SurfaceRasterizer.currentEntry(doc, now); + state = entry == null ? new HashMap() + : asMap(entry.get("state")); + } + int index = sizeIndex(sizeName); + Map wrapped = wrapRounded(layout, roleColor("background"), CORNER); + Map scaled = scaleNodeDips(wrapped, SCALE); + SurfaceRasterizer.Result result = SurfaceRasterizer.rasterize(scaled, state, + images, SIZE_W[index] * SCALE, SIZE_H[index] * SCALE, isSystemDark(), now); + showResult(result, SIZE_W[index] * SCALE, SIZE_H[index] * SCALE, + SurfaceRasterizer.nextEntryFlip(doc, now)); + } + + @Override + String actionSource() { + return kindId; + } + } + + /** A running live activity: descriptor document, images and the latest state map. */ + private static final class LiveActivityRecord { + final Map descriptor; + final Map images; + Map state; + String id; + boolean ended; + + LiveActivityRecord(Map descriptor, Map images, + Map state) { + this.descriptor = descriptor; + this.images = images; + this.state = state; + } + } + + /** The live activity pill docked at the top-center of the screen; always dark. */ + private final class PillWindow extends SurfaceWindow { + final LiveActivityRecord record; + + PillWindow(LiveActivityRecord record) { + this.record = record; + peer = LinuxNative.widgetCreate(PILL_W, PILL_H); + if (peer != 0) { + // x == -1 centers horizontally (computed natively from the screen width) + LinuxNative.widgetSetPosition(peer, -1, PILL_TOP); + } + } + + @Override + void renderNow() { + if (peer == 0) { + return; + } + Map pillNode = buildPillNode(record.descriptor); + Map wrapped = wrapRounded(pillNode, + argbColor(0xff000000), PILL_H / 2); + Map scaled = scaleNodeDips(wrapped, SCALE); + // the pill is always dark, matching the hardware cutout it mimics + SurfaceRasterizer.Result result = SurfaceRasterizer.rasterize(scaled, record.state, + record.images, PILL_W * SCALE, PILL_H * SCALE, true, + System.currentTimeMillis()); + showResult(result, PILL_W * SCALE, PILL_H * SCALE, 0); + } + + @Override + String actionSource() { + Object type = record.descriptor.get("type"); + return type instanceof String ? (String) type : record.id; + } + } + + /* --------------------------------------------------------- node helpers */ + + /** + * Synthesizes the compact pill layout from the descriptor's Dynamic Island regions: + * compact leading and trailing pushed apart by a spacer, falling back to the minimal + * region or the activity type name (mirrors the JavaSE simulator's pill). + */ + private static Map buildPillNode(Map descriptor) { + Map island = asMap(descriptor.get("island")); + Object leading = island.get("compactLeading"); + Object trailing = island.get("compactTrailing"); + Map row = new LinkedHashMap(); + row.put("t", "row"); + List pad = new ArrayList(); + pad.add(Integer.valueOf(4)); + pad.add(Integer.valueOf(14)); + pad.add(Integer.valueOf(4)); + pad.add(Integer.valueOf(14)); + row.put("pad", pad); + row.put("spacing", Integer.valueOf(8)); + List children = new ArrayList(); + if (leading instanceof Map || trailing instanceof Map) { + if (leading instanceof Map) { + children.add(leading); + } + Map spacer = new LinkedHashMap(); + spacer.put("t", "spacer"); + children.add(spacer); + if (trailing instanceof Map) { + children.add(trailing); + } + } else if (island.get("minimal") instanceof Map) { + children.add(island.get("minimal")); + } else { + Map label = new LinkedHashMap(); + label.put("t", "text"); + Object type = descriptor.get("type"); + label.put("text", type instanceof String ? type : "live activity"); + label.put("size", Integer.valueOf(12)); + children.add(label); + } + row.put("ch", children); + return row; + } + + /** + * Wraps a layout in a rounded background box so the rasterized alpha carries the + * window's shape (the RGBA visual turns it into real translucent corners). + */ + private static Map wrapRounded(Map node, + Map bg, int corner) { + Map box = new LinkedHashMap(); + box.put("t", "box"); + box.put("bg", bg); + box.put("corner", Integer.valueOf(corner)); + List children = new ArrayList(); + children.add(node); + box.put("ch", children); + return box; + } + + private static Map placeholderNode(String name) { + Map text = new LinkedHashMap(); + text.put("t", "text"); + text.put("text", name); + text.put("size", Integer.valueOf(14)); + return text; + } + + private static Map roleColor(String role) { + Map color = new LinkedHashMap(); + color.put("role", role); + return color; + } + + private static Map argbColor(int argb) { + Map color = new LinkedHashMap(); + color.put("l", Integer.valueOf(argb)); + color.put("d", Integer.valueOf(argb)); + return color; + } + + /** + * Deep-copies a descriptor node multiplying every dip-valued attribute by {@code scale}, + * so the rasterizer (which treats dips as pixels) produces a crisp scaled rendering; the + * action rectangles it returns are then in scaled pixels as well. Local replica of the + * JavaSE bridge helper (ports never import across each other). + */ + @SuppressWarnings("unchecked") + private static Map scaleNodeDips(Map node, int scale) { + if (node == null || scale == 1) { + return node; + } + Map out = new LinkedHashMap(); + for (Map.Entry e : node.entrySet()) { + String key = e.getKey(); + Object value = e.getValue(); + if ("ch".equals(key) && value instanceof List) { + List children = new ArrayList(); + for (Object child : (List) value) { + if (child instanceof Map) { + children.add(scaleNodeDips((Map) child, scale)); + } else { + children.add(child); + } + } + out.put(key, children); + } else if ("pad".equals(key) && value instanceof List) { + List pad = new ArrayList(); + for (Object p : (List) value) { + pad.add(p instanceof Number + ? (Object) Integer.valueOf(((Number) p).intValue() * scale) : p); + } + out.put(key, pad); + } else if (isDipKey(key) && value instanceof Number) { + out.put(key, Integer.valueOf(((Number) value).intValue() * scale)); + } else { + out.put(key, value); + } + } + return out; + } + + private static boolean isDipKey(String key) { + return "w".equals(key) || "h".equals(key) || "corner".equals(key) + || "spacing".equals(key) || "min".equals(key) || "size".equals(key); + } + + /* ------------------------------------------------------------ persistence */ + + private String kindDir(String kindId) { + String dir = root + "/" + sanitize(kindId); + if (!LinuxNative.fileExists(dir)) { + LinuxNative.fileMkdir(dir); + } + return dir; + } + + private static String sanitize(String kindId) { + StringBuilder sb = new StringBuilder(kindId.length()); + for (int i = 0; i < kindId.length(); i++) { + char c = kindId.charAt(i); + if (Character.isLetterOrDigit(c) || c == '_' || c == '-') { + sb.append(c); + } else { + sb.append('_'); + } + } + return sb.toString(); + } + + /** Atomic write-rename so a platform re-render never reads a torn file. */ + private static void writeFileSafely(String path, byte[] data) { + String tmp = path + ".tmp"; + long handle = LinuxNative.fileOpenWrite(tmp, false); + if (handle == 0) { + return; + } + LinuxNative.fileWrite(handle, data, 0, data.length); + LinuxNative.fileClose(handle); + if (LinuxNative.fileExists(path)) { + LinuxNative.fileDelete(path); + } + // fileRename takes a leaf name and renames within the same directory + LinuxNative.fileRename(tmp, leafOf(path)); + } + + private static String leafOf(String path) { + int slash = path.lastIndexOf('/'); + return slash < 0 ? path : path.substring(slash + 1); + } + + private static byte[] readFile(String path) { + if (!LinuxNative.fileExists(path)) { + return null; + } + long handle = LinuxNative.fileOpenRead(path); + if (handle == 0) { + return null; + } + try { + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int len = LinuxNative.fileRead(handle, buf, 0, buf.length); + while (len > 0) { + bo.write(buf, 0, len); + len = LinuxNative.fileRead(handle, buf, 0, buf.length); + } + return bo.toByteArray(); + } finally { + LinuxNative.fileClose(handle); + } + } + + private void restoreFromDisk() { + String[] dirs = LinuxNative.fileList(root); + if (dirs == null) { + return; + } + for (String name : dirs) { + String dir = root + "/" + name; + if (!LinuxNative.fileIsDirectory(dir)) { + continue; + } + String kindId = name; + byte[] kindData = readFile(dir + "/kind.json"); + if (kindData != null) { + Map kind = parse(fromUtf8(kindData)); + if (kind != null && kind.get("id") instanceof String) { + kindId = (String) kind.get("id"); + kinds.put(kindId, kind); + } + } + byte[] timelineData = readFile(dir + "/timeline.json"); + if (timelineData != null) { + Map doc = parse(fromUtf8(timelineData)); + if (doc != null) { + timelines.put(kindId, doc); + kindImages.put(kindId, readImages(dir)); + } + } + } + } + + private static Map readImages(String dir) { + Map images = new HashMap(); + String[] files = LinuxNative.fileList(dir); + if (files == null) { + return images; + } + for (String name : files) { + if (name.endsWith(".png")) { + byte[] data = readFile(dir + "/" + name); + if (data != null) { + images.put(name.substring(0, name.length() - 4), data); + } + } + } + return images; + } + + /* ---------------------------------------------------------------- misc */ + + @SuppressWarnings("unchecked") + private static Map parse(String json) { + if (json == null) { + return null; + } + try { + return new JSONParser().parseJSON(new StringReader(json)); + } catch (Exception err) { + err.printStackTrace(); + return null; + } + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object o) { + return o instanceof Map ? (Map) o : new HashMap(); + } + + private static byte[] utf8(String s) { + try { + return s.getBytes("UTF-8"); + } catch (UnsupportedEncodingException err) { + return s.getBytes(); + } + } + + private static String fromUtf8(byte[] data) { + try { + return new String(data, "UTF-8"); + } catch (UnsupportedEncodingException err) { + return new String(data); + } + } + + private static String normalizeSize(String sizeName) { + for (String s : SIZE_NAMES) { + if (s.equals(sizeName)) { + return s; + } + } + return "small"; + } + + private static int sizeIndex(String sizeName) { + for (int i = 0; i < SIZE_NAMES.length; i++) { + if (SIZE_NAMES[i].equals(sizeName)) { + return i; + } + } + return 0; + } + + /** Follows the display's dark-mode flag when available; defaults to light. */ + private static boolean isSystemDark() { + // Deliberately assign-in-try / decide-outside-try: returning a freshly + // merged boolean from inside the translated try block ICEs Alpine gcc + // (SSA corruption) on the ParparVM-generated C for this method. + Boolean dark = null; + try { + dark = Display.getInstance().isDarkMode(); + } catch (Throwable t) { + // headless or uninitialized display: default to light + } + return dark != null && dark.booleanValue(); + } + + /** Basic split (the clean target avoids regex-based String.split). */ + private static String[] split(String s, char sep) { + List out = new ArrayList(); + int start = 0; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == sep) { + out.add(s.substring(start, i)); + start = i + 1; + } + } + out.add(s.substring(start)); + return out.toArray(new String[out.size()]); + } +} diff --git a/Ports/WindowsPort/nativeSources/cn1_windows.h b/Ports/WindowsPort/nativeSources/cn1_windows.h index f044e2a0611..ef19afd7345 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows.h +++ b/Ports/WindowsPort/nativeSources/cn1_windows.h @@ -302,6 +302,27 @@ void cn1WinShareHandleMessage(WPARAM wParam); #define WM_CN1_PRINTDLG (WM_APP + 23) LRESULT cn1WinPrintDialogHandleMessage(WPARAM wParam); +/* Floating layered widget windows (cn1_windows_widgets.cpp): the desktop + * lowering of com.codename1.surfaces. Widget windows must be created, updated + * and destroyed on the window-owning pump thread (a window belongs to the + * thread that created it), so the EDT-facing widget natives post WM_CN1_WIDGET + * to cn1Win.hwnd with a heap-allocated op struct (create / destroy / setpixels + * / setpos / sethitrects / focusapp) that the handler frees -- the same + * marshaling pattern WM_CN1_NOTIFY uses, never a blocking SendMessage from the + * EDT. Events flow back through a mutex-guarded string queue the EDT polls + * (widgetPollEvent), mirroring browserPollEvent. */ +#define WM_CN1_WIDGET (WM_APP + 24) +void cn1WinWidgetHandleMessage(WPARAM wParam); + +#ifdef CN1_WIDGETBOARD +/* Windows 11 Widgets Board provider (cn1_windows_widgetboard.cpp), compiled + * only when the MSIX build defines CN1_WIDGETBOARD (windows.msix=true). When + * Windows activated this exe with -RegisterProcessAsComServer the call runs the + * out-of-process COM widget provider and exits the process; in a normal app + * launch it returns immediately. */ +void cn1WidgetBoardMaybeRunComServer(void); +#endif + /* graphics (cn1_windows_graphics.c) */ CN1Graphics* cn1WinCreateGraphics(ID2D1RenderTarget* target); void cn1WinBeginFrame(CN1Graphics* g); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_widgetboard.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_widgetboard.cpp new file mode 100644 index 00000000000..1e6551053d2 --- /dev/null +++ b/Ports/WindowsPort/nativeSources/cn1_windows_widgetboard.cpp @@ -0,0 +1,1029 @@ +/* + * 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. + */ + +/* + * Windows 11 Widgets Board provider: the MSIX lowering of + * com.codename1.surfaces. This whole file is compile-gated behind + * CN1_WIDGETBOARD, which WindowsNativeBuilder defines only for + * windows.msix=true builds -- the default CMake build has no Windows App SDK + * headers and is completely unaffected (the only unguarded symbol is absent; + * the sole call site in cn1_windows_window.cpp is equally guarded). + * + * How it works + * ------------ + * The Widgets Board hosts third-party widgets rendered from Adaptive Cards + * JSON. A packaged (MSIX) app declares: + * - a COM ExeServer (`app.exe -RegisterProcessAsComServer`) exposing + * CN1_WIDGET_PROVIDER_CLSID, and + * - a `com.microsoft.windows.widgets` uap3:AppExtension whose widget + * Definitions are generated from the project's surfaces.json. + * When the user pins a widget, Windows activates the exe with + * -RegisterProcessAsComServer; cn1WidgetBoardMaybeRunComServer() (called from + * the first native of every launch, initDisplay) detects the flag, bootstraps + * the WindowsAppRuntime, registers the class factory and serves + * IWidgetProvider calls until the board deactivates the last widget -- the + * Codename One app UI never starts in this mode. + * + * Content comes from the same persisted surface data the in-app bridge + * (WindowsWidgetBridge) writes: %LOCALAPPDATA%\CodenameOne\cn1surfaces\\ + * timeline.json + .png. (Under MSIX the path is transparently redirected + * into the package's AppData view; both the app and this provider run with the + * same package identity, so they see the same files.) The descriptor is mapped + * to an Adaptive Card, documented approximations and all: + * + * col / row / box -> Container / ColumnSet / Container (AC has no overlay + * stacking, so box degrades to a vertical Container) + * text -> TextBlock (size/weight best-effort buckets; role colors + * map to Default/Accent/isSubtle; explicit ARGB colors are + * not representable in AC and fall back to Default) + * dyn -> TextBlock with a statically computed value; AC has no + * countdown primitive, so while any widget is active a + * timer re-templates and re-pushes the card once a minute + * (second-precision ticking is not possible on the board) + * img -> Image with a data: URI (base64 of the persisted PNG) + * prog -> two-Column ColumnSet emulating a bar: proportional + * pixel widths, filled column styled "accent" + * spacer -> stretch Column in a row / empty TextBlock in a column + * + * Actions: nodes carrying an action id become Action.Execute selectActions + * with the action id as the verb. OnActionInvoked appends + * "source\tactionId\tparamsJson" to cn1surfaces\pending_actions.txt and + * launches the app exe; WindowsWidgetBridge drains that file on startup into + * Surfaces.dispatchAction's cold-start queue. + * + * Runtime dependency + * ------------------ + * The provider APIs live in the Windows App SDK (WindowsAppRuntime). Because + * the CN1 exe is a plain win32 app (not framework-dependent at build time), + * MddBootstrapInitialize dynamically binds the installed WindowsAppRuntime + * package at startup; the target machine must have the matching + * WindowsAppRuntime redistributable installed (documented in + * WindowsNativeBuilder next to the MSIX packaging step). Building this file + * additionally needs the Windows App SDK headers/libs + C++/WinRT projections, + * pointed at by the CN1_WINAPPSDK_DIR environment variable at build time. + * + * NOTE: this translation unit cannot be compiled or exercised outside a + * Windows host with the Windows App SDK present; it is reviewed-not-compiled + * on other platforms and is kept intentionally self-contained. + */ + +#ifdef CN1_WIDGETBOARD + +/* Include the C++ standard library and SDK headers BEFORE cn1_windows.h: + * cn1_globals.h installs macros for the bytecode runtime that otherwise break + * the STL headers. */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Classic COM interop: must precede winrt/base.h so C++/WinRT + * enables its ::IUnknown integration -- this file implements a classic + * IClassFactory through winrt::implements and converts GUID <-> winrt::guid in + * CreateInstance's `.as(iid, result)`. The full above already pulls + * unknwn.h in transitively (WIN32_LEAN_AND_MEAN is not defined yet at this + * point), but the projection would silently degrade if that ever changed, so + * the dependency is spelled out. */ +#include + +/* C++/WinRT + Windows App SDK (projection headers generated with cppwinrt.exe + * from the WinAppSDK winmd into the SDK layout CN1_WINAPPSDK_DIR points at). */ +#include +#include +#include +#include +#include + +/* Deliberately NOT including cn1_windows.h: it pulls cn1_globals.h, whose C11 + * stdatomic.h usage conflicts with the C++ the winrt headers pull + * into this TU. The only symbol this file needs from the port is the logger, + * forward-declared with matching C linkage. */ +extern "C" void cn1WindowsLog(const char* message); + +#endif /* CN1_WIDGETBOARD */ + +#ifdef CN1_WIDGETBOARD + +using namespace winrt::Microsoft::Windows::Widgets::Providers; +using winrt::Microsoft::Windows::Widgets::WidgetSize; + +/* Must match the com:Class Id in the AppxManifest WindowsNativeBuilder + * generates (WIDGET_PROVIDER_CLSID there). */ +static const CLSID CN1_WIDGET_PROVIDER_CLSID = + { 0xc0de4a11, 0x5a2f, 0x4e7b, { 0x9c, 0x61, 0x7d, 0x1b, 0x4a, 0x0c, 0x8e, 0x52 } }; + +/* Windows App SDK 1.5 release series; the bootstrap binds any installed + * runtime with this major.minor and a compatible version tag. */ +#define CN1_WINAPPSDK_MAJORMINOR 0x00010005 + +/* ============================================================== tiny JSON */ + +/* + * A minimal recursive-descent JSON reader for the surfaces wire format. The + * descriptors are machine-generated by SurfaceSerializer (no exotic escapes, + * no comments), so this deliberately supports exactly RFC 8259 objects, + * arrays, strings (with the standard escapes), numbers, booleans and null. + */ +struct CN1Json { + enum Type { JNULL, JBOOL, JNUM, JSTR, JARR, JOBJ }; + Type type = JNULL; + bool boolean = false; + double number = 0; + std::string str; + std::vector arr; + std::map obj; + + bool isMap() const { return type == JOBJ; } + bool isNum() const { return type == JNUM; } + bool isStr() const { return type == JSTR; } + const CN1Json* get(const char* key) const { + if (type != JOBJ) return NULL; + std::map::const_iterator it = obj.find(key); + return it == obj.end() ? NULL : &it->second; + } + std::string getStr(const char* key, const char* def) const { + const CN1Json* v = get(key); + return (v != NULL && v->type == JSTR) ? v->str : std::string(def); + } + double getNum(const char* key, double def) const { + const CN1Json* v = get(key); + return (v != NULL && v->type == JNUM) ? v->number : def; + } +}; + +struct CN1JsonParser { + const char* p; + const char* end; + + CN1JsonParser(const std::string& s) : p(s.c_str()), end(s.c_str() + s.size()) {} + + void skipWs() { + while (p < end && (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n')) p++; + } + bool literal(const char* lit) { + size_t n = strlen(lit); + if ((size_t) (end - p) >= n && strncmp(p, lit, n) == 0) { p += n; return true; } + return false; + } + bool parseString(std::string& out) { + if (p >= end || *p != '"') return false; + p++; + out.clear(); + while (p < end && *p != '"') { + if (*p == '\\' && p + 1 < end) { + p++; + switch (*p) { + case 'n': out += '\n'; break; + case 't': out += '\t'; break; + case 'r': out += '\r'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'u': { + /* decode \uXXXX to UTF-8 (surrogate pairs handled) */ + if (end - p < 5) return false; + unsigned cp = (unsigned) strtoul(std::string(p + 1, p + 5).c_str(), NULL, 16); + p += 4; + if (cp >= 0xD800 && cp <= 0xDBFF && end - p >= 7 + && p[1] == '\\' && p[2] == 'u') { + unsigned lo = (unsigned) strtoul(std::string(p + 3, p + 7).c_str(), NULL, 16); + if (lo >= 0xDC00 && lo <= 0xDFFF) { + cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); + p += 6; + } + } + if (cp < 0x80) { + out += (char) cp; + } else if (cp < 0x800) { + out += (char) (0xC0 | (cp >> 6)); + out += (char) (0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + out += (char) (0xE0 | (cp >> 12)); + out += (char) (0x80 | ((cp >> 6) & 0x3F)); + out += (char) (0x80 | (cp & 0x3F)); + } else { + out += (char) (0xF0 | (cp >> 18)); + out += (char) (0x80 | ((cp >> 12) & 0x3F)); + out += (char) (0x80 | ((cp >> 6) & 0x3F)); + out += (char) (0x80 | (cp & 0x3F)); + } + break; + } + default: out += *p; break; + } + p++; + } else { + out += *p++; + } + } + if (p >= end) return false; + p++; /* closing quote */ + return true; + } + bool parseValue(CN1Json& out) { + skipWs(); + if (p >= end) return false; + if (*p == '{') { + p++; + out.type = CN1Json::JOBJ; + skipWs(); + if (p < end && *p == '}') { p++; return true; } + for (;;) { + skipWs(); + std::string key; + if (!parseString(key)) return false; + skipWs(); + if (p >= end || *p != ':') return false; + p++; + CN1Json value; + if (!parseValue(value)) return false; + out.obj[key] = value; + skipWs(); + if (p < end && *p == ',') { p++; continue; } + if (p < end && *p == '}') { p++; return true; } + return false; + } + } + if (*p == '[') { + p++; + out.type = CN1Json::JARR; + skipWs(); + if (p < end && *p == ']') { p++; return true; } + for (;;) { + CN1Json value; + if (!parseValue(value)) return false; + out.arr.push_back(value); + skipWs(); + if (p < end && *p == ',') { p++; continue; } + if (p < end && *p == ']') { p++; return true; } + return false; + } + } + if (*p == '"') { + out.type = CN1Json::JSTR; + return parseString(out.str); + } + if (literal("true")) { out.type = CN1Json::JBOOL; out.boolean = true; return true; } + if (literal("false")) { out.type = CN1Json::JBOOL; out.boolean = false; return true; } + if (literal("null")) { out.type = CN1Json::JNULL; return true; } + /* number */ + char* numEnd = NULL; + double v = strtod(p, &numEnd); + if (numEnd == p) return false; + out.type = CN1Json::JNUM; + out.number = v; + p = numEnd; + return true; + } +}; + +static bool cn1JsonParse(const std::string& text, CN1Json& out) { + CN1JsonParser parser(text); + return parser.parseValue(out); +} + +/* ======================================================== file utilities */ + +/* The same persistence root WindowsWidgetBridge writes: + * %LOCALAPPDATA%\CodenameOne\cn1surfaces (redirected into the package view + * under MSIX -- both processes share the package identity). */ +static std::wstring cn1BoardSurfacesRoot() { + WCHAR base[MAX_PATH]; + if (SHGetFolderPathW(NULL, CSIDL_LOCAL_APPDATA, NULL, SHGFP_TYPE_CURRENT, base) != S_OK) { + return std::wstring(); + } + std::wstring root(base); + root += L"\\CodenameOne\\cn1surfaces"; + return root; +} + +static bool cn1BoardReadFile(const std::wstring& path, std::string& out) { + HANDLE f = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (f == INVALID_HANDLE_VALUE) { + return false; + } + out.clear(); + char buf[8192]; + DWORD n = 0; + while (ReadFile(f, buf, sizeof(buf), &n, NULL) && n > 0) { + out.append(buf, (size_t) n); + } + CloseHandle(f); + return true; +} + +static std::wstring cn1BoardUtf8ToWide(const std::string& s) { + if (s.empty()) return std::wstring(); + int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int) s.size(), NULL, 0); + std::wstring w((size_t) n, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int) s.size(), &w[0], n); + return w; +} + +static const char CN1_B64_ALPHABET[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +static std::string cn1BoardBase64(const std::string& data) { + std::string out; + out.reserve(((data.size() + 2) / 3) * 4); + size_t i = 0; + while (i + 2 < data.size()) { + unsigned v = ((unsigned char) data[i] << 16) + | ((unsigned char) data[i + 1] << 8) | (unsigned char) data[i + 2]; + out += CN1_B64_ALPHABET[(v >> 18) & 63]; + out += CN1_B64_ALPHABET[(v >> 12) & 63]; + out += CN1_B64_ALPHABET[(v >> 6) & 63]; + out += CN1_B64_ALPHABET[v & 63]; + i += 3; + } + if (i + 1 == data.size()) { + unsigned v = ((unsigned char) data[i]) << 16; + out += CN1_B64_ALPHABET[(v >> 18) & 63]; + out += CN1_B64_ALPHABET[(v >> 12) & 63]; + out += "=="; + } else if (i + 2 == data.size()) { + unsigned v = (((unsigned char) data[i]) << 16) | (((unsigned char) data[i + 1]) << 8); + out += CN1_B64_ALPHABET[(v >> 18) & 63]; + out += CN1_B64_ALPHABET[(v >> 12) & 63]; + out += CN1_B64_ALPHABET[(v >> 6) & 63]; + out += '='; + } + return out; +} + +/* JSON string escaping for the emitted Adaptive Card. */ +static std::string cn1BoardJsonEscape(const std::string& s) { + std::string out; + out.reserve(s.size() + 8); + for (size_t i = 0; i < s.size(); i++) { + char c = s[i]; + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if ((unsigned char) c < 0x20) { + char esc[8]; + _snprintf(esc, sizeof(esc), "\\u%04x", (unsigned char) c); + out += esc; + } else { + out += c; + } + break; + } + } + return out; +} + +/* ================================================== descriptor -> AC JSON */ + +/* ${key} interpolation from the entry's state map (missing keys -> empty). */ +static std::string cn1BoardInterpolate(const std::string& text, const CN1Json* state) { + if (text.find("${") == std::string::npos) { + return text; + } + std::string out; + size_t i = 0; + while (i < text.size()) { + size_t start = text.find("${", i); + if (start == std::string::npos) { + out += text.substr(i); + break; + } + size_t close = text.find('}', start + 2); + if (close == std::string::npos) { + out += text.substr(i); + break; + } + out += text.substr(i, start - i); + std::string key = text.substr(start + 2, close - start - 2); + if (state != NULL) { + const CN1Json* v = state->get(key.c_str()); + if (v != NULL) { + if (v->type == CN1Json::JSTR) { + out += v->str; + } else if (v->type == CN1Json::JNUM) { + char buf[48]; + if (v->number == (long long) v->number) { + _snprintf(buf, sizeof(buf), "%lld", (long long) v->number); + } else { + _snprintf(buf, sizeof(buf), "%g", v->number); + } + out += buf; + } else if (v->type == CN1Json::JBOOL) { + out += v->boolean ? "true" : "false"; + } + } + } + i = close + 1; + } + return out; +} + +/* Static rendering of dynamic text (the SurfaceRasterizer formatting rules); + * the provider timer re-pushes the card once a minute while widgets are + * active, so timers advance in minute steps on the board. */ +static std::string cn1BoardFormatDynamic(const std::string& style, long long dateMillis, + long long nowMillis) { + char buf[64]; + if (style == "time" || style == "date") { + time_t t = (time_t) (dateMillis / 1000); + struct tm tmv; + localtime_s(&tmv, &t); + if (style == "time") { + int hour = tmv.tm_hour % 12; + if (hour == 0) hour = 12; + _snprintf(buf, sizeof(buf), "%d:%02d %s", hour, tmv.tm_min, + tmv.tm_hour < 12 ? "AM" : "PM"); + } else { + static const char* months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; + _snprintf(buf, sizeof(buf), "%s %d", months[tmv.tm_mon], tmv.tm_mday); + } + return buf; + } + if (style == "relative") { + long long diff = dateMillis - nowMillis; + bool future = diff >= 0; + long long minutes = (diff < 0 ? -diff : diff) / 60000; + if (minutes < 1) return "now"; + if (minutes < 60) { + _snprintf(buf, sizeof(buf), future ? "in %lld min" : "%lld min ago", minutes); + } else if (minutes < 24 * 60) { + _snprintf(buf, sizeof(buf), future ? "in %lld hr" : "%lld hr ago", minutes / 60); + } else { + long long days = minutes / (24 * 60); + _snprintf(buf, sizeof(buf), future ? "in %lld day%s" : "%lld day%s ago", days, + days == 1 ? "" : "s"); + } + return buf; + } + /* timerDown (default) / timerUp */ + long long total = (style == "timerUp" ? nowMillis - dateMillis : dateMillis - nowMillis) / 1000; + if (total < 0) total = 0; + long long hours = total / 3600; + long long minutes = (total % 3600) / 60; + long long seconds = total % 60; + if (hours > 0) { + _snprintf(buf, sizeof(buf), "%lld:%02lld:%02lld", hours, minutes, seconds); + } else { + _snprintf(buf, sizeof(buf), "%lld:%02lld", minutes, seconds); + } + return buf; +} + +/* Best-effort TextBlock attributes from the node's size/weight/color. */ +static void cn1BoardTextAttributes(const CN1Json& node, std::string& out) { + double size = node.getNum("size", 0); + if (size > 0) { + if (size <= 12) out += ",\"size\":\"Small\""; + else if (size <= 16) out += ""; /* Default */ + else if (size <= 20) out += ",\"size\":\"Medium\""; + else if (size <= 28) out += ",\"size\":\"Large\""; + else out += ",\"size\":\"ExtraLarge\""; + } + std::string weight = node.getStr("fw", "regular"); + if (weight == "semibold" || weight == "bold") { + out += ",\"weight\":\"Bolder\""; + } else if (weight == "light") { + out += ",\"weight\":\"Lighter\""; + } + const CN1Json* color = node.get("color"); + if (color != NULL && color->isMap()) { + std::string role = color->getStr("role", ""); + if (role == "accent") { + out += ",\"color\":\"Accent\""; + } else if (role == "secondaryLabel") { + out += ",\"isSubtle\":true"; + } + /* explicit {l,d} ARGB pairs are not representable in Adaptive Cards; + * they render with the theme's Default color */ + } +} + +/* Appends the node's action as an AC selectAction (Action.Execute with the + * action id as the verb); also records the payload so OnActionInvoked can + * relay it to the app. */ +static void cn1BoardAppendAction(const CN1Json& node, std::string& out) { + const CN1Json* action = node.get("action"); + if (action == NULL || !action->isMap()) { + return; + } + std::string id = action->getStr("id", ""); + if (id.empty()) { + return; + } + out += ",\"selectAction\":{\"type\":\"Action.Execute\",\"verb\":\"" + + cn1BoardJsonEscape(id) + "\"}"; +} + +static std::string cn1BoardNodeToElement(const CN1Json& node, const CN1Json* state, + const std::wstring& kindDir, long long nowMillis, int depth); + +/* Children of col/box stack vertically inside a Container. */ +static std::string cn1BoardChildrenToItems(const CN1Json& node, const CN1Json* state, + const std::wstring& kindDir, long long nowMillis, int depth) { + std::string out = "["; + const CN1Json* ch = node.get("ch"); + bool first = true; + if (ch != NULL && ch->type == CN1Json::JARR) { + for (size_t i = 0; i < ch->arr.size(); i++) { + if (!ch->arr[i].isMap()) continue; + std::string element = cn1BoardNodeToElement(ch->arr[i], state, kindDir, + nowMillis, depth + 1); + if (element.empty()) continue; + if (!first) out += ","; + out += element; + first = false; + } + } + out += "]"; + return out; +} + +static std::string cn1BoardNodeToElement(const CN1Json& node, const CN1Json* state, + const std::wstring& kindDir, long long nowMillis, int depth) { + if (depth > 8) { + return std::string(); /* same nesting cap as the wire format */ + } + std::string t = node.getStr("t", "box"); + + if (t == "text") { + std::string text = cn1BoardInterpolate(node.getStr("text", ""), state); + std::string out = "{\"type\":\"TextBlock\",\"wrap\":true,\"text\":\"" + + cn1BoardJsonEscape(text) + "\""; + cn1BoardTextAttributes(node, out); + cn1BoardAppendAction(node, out); + out += "}"; + return out; + } + + if (t == "dyn") { + long long date = 0; + std::string dateKey = node.getStr("dateKey", ""); + if (!dateKey.empty()) { + const CN1Json* v = state == NULL ? NULL : state->get(dateKey.c_str()); + if (v == NULL || !v->isNum()) return std::string(); + date = (long long) v->number; + } else { + const CN1Json* v = node.get("date"); + if (v == NULL || !v->isNum()) return std::string(); + date = (long long) v->number; + } + std::string text = cn1BoardFormatDynamic(node.getStr("style", "timerDown"), + date, nowMillis); + std::string out = "{\"type\":\"TextBlock\",\"text\":\"" + + cn1BoardJsonEscape(text) + "\""; + cn1BoardTextAttributes(node, out); + cn1BoardAppendAction(node, out); + out += "}"; + return out; + } + + if (t == "img") { + std::string name = node.getStr("name", ""); + if (name.empty()) return std::string(); + std::string png; + if (!cn1BoardReadFile(kindDir + L"\\" + cn1BoardUtf8ToWide(name) + L".png", png)) { + return std::string(); + } + std::string out = "{\"type\":\"Image\",\"url\":\"data:image/png;base64," + + cn1BoardBase64(png) + "\""; + if (node.getStr("scale", "fit") == "fill") { + out += ",\"size\":\"Stretch\""; + } + cn1BoardAppendAction(node, out); + out += "}"; + return out; + } + + if (t == "prog") { + /* Pre-1.6 Adaptive Cards have no ProgressBar: emulate a determinate + * bar with a two-column ColumnSet whose widths are proportional; the + * filled column is styled "accent". Circular degrades to the same bar. */ + double fraction = 0; + std::string valueKey = node.getStr("valueKey", ""); + if (!valueKey.empty()) { + const CN1Json* v = state == NULL ? NULL : state->get(valueKey.c_str()); + if (v != NULL && v->isNum()) fraction = v->number; + } else if (node.get("start") != NULL && node.get("start")->isNum() + && node.get("end") != NULL && node.get("end")->isNum()) { + double start = node.get("start")->number; + double endv = node.get("end")->number; + if (endv > start) fraction = (nowMillis - start) / (endv - start); + } else if (node.get("value") != NULL && node.get("value")->isNum()) { + fraction = node.get("value")->number; + } + if (fraction < 0) fraction = 0; + if (fraction > 1) fraction = 1; + int filled = (int) (fraction * 100 + 0.5); + if (filled < 1) filled = 1; /* AC rejects zero-weight columns */ + int rest = 100 - filled; + if (rest < 1) rest = 1; + char buf[512]; + _snprintf(buf, sizeof(buf), + "{\"type\":\"ColumnSet\",\"spacing\":\"Small\",\"columns\":[" + "{\"type\":\"Column\",\"width\":%d,\"style\":\"accent\",\"items\":" + "[{\"type\":\"TextBlock\",\"text\":\" \",\"size\":\"Small\"}]}," + "{\"type\":\"Column\",\"width\":%d,\"style\":\"emphasis\",\"items\":" + "[{\"type\":\"TextBlock\",\"text\":\" \",\"size\":\"Small\"}]}]}", + filled, rest); + return buf; + } + + if (t == "spacer") { + /* meaningful only inside a row (handled there as a stretch column); in + * a column it degrades to a small vertical gap */ + return "{\"type\":\"TextBlock\",\"text\":\" \",\"spacing\":\"Medium\"}"; + } + + if (t == "row") { + std::string out = "{\"type\":\"ColumnSet\",\"columns\":["; + const CN1Json* ch = node.get("ch"); + bool first = true; + if (ch != NULL && ch->type == CN1Json::JARR) { + for (size_t i = 0; i < ch->arr.size(); i++) { + if (!ch->arr[i].isMap()) continue; + const CN1Json& child = ch->arr[i]; + std::string childType = child.getStr("t", "box"); + bool stretch = child.getNum("weight", 0) > 0 || childType == "spacer"; + std::string inner = childType == "spacer" ? std::string() + : cn1BoardNodeToElement(child, state, kindDir, nowMillis, depth + 1); + if (!first) out += ","; + out += "{\"type\":\"Column\",\"width\":\""; + out += stretch ? "stretch" : "auto"; + out += "\",\"items\":["; + if (!inner.empty()) out += inner; + out += "]}"; + first = false; + } + } + out += "]"; + cn1BoardAppendAction(node, out); + out += "}"; + return out; + } + + /* col / box (and unknown types) -> Container; AC has no overlay stacking, + * so a box's children stack vertically like a column */ + std::string out = "{\"type\":\"Container\",\"items\":" + + cn1BoardChildrenToItems(node, state, kindDir, nowMillis, depth); + const CN1Json* bg = node.get("bg"); + if (bg != NULL && bg->isMap() && bg->getStr("role", "") == "accent") { + out += ",\"style\":\"accent\""; + } + cn1BoardAppendAction(node, out); + out += "}"; + return out; +} + +/* Maps a widget-board size name to the wire-format layout size name. */ +static std::string cn1BoardLayoutSizeName(const std::string& boardSize) { + if (boardSize == "medium") return "medium"; + if (boardSize == "large") return "large"; + return "small"; +} + +/* + * Builds the full Adaptive Card for a kind + board size from the persisted + * timeline: picks the per-size (or default) layout, resolves the active entry + * (latest date <= now) and maps the tree. Returns an empty string when there + * is no published content yet (the board then keeps the default loading card). + */ +static std::string cn1BoardBuildCard(const std::string& kindId, const std::string& boardSize) { + std::wstring root = cn1BoardSurfacesRoot(); + if (root.empty()) { + return std::string(); + } + std::wstring kindDir = root + L"\\" + cn1BoardUtf8ToWide(kindId); + std::string timelineText; + if (!cn1BoardReadFile(kindDir + L"\\timeline.json", timelineText)) { + return std::string(); + } + CN1Json doc; + if (!cn1JsonParse(timelineText, doc) || !doc.isMap()) { + return std::string(); + } + /* layout for size, falling back to "default" */ + const CN1Json* layouts = doc.get("layouts"); + if (layouts == NULL || !layouts->isMap()) { + return std::string(); + } + const CN1Json* layout = layouts->get(cn1BoardLayoutSizeName(boardSize).c_str()); + if (layout == NULL || !layout->isMap()) { + layout = layouts->get("default"); + } + if (layout == NULL || !layout->isMap()) { + return std::string(); + } + /* active entry: the entry whose date most recently passed */ + long long nowMillis = (long long) time(NULL) * 1000LL; + const CN1Json* state = NULL; + const CN1Json* entries = doc.get("entries"); + if (entries != NULL && entries->type == CN1Json::JARR) { + const CN1Json* current = NULL; + for (size_t i = 0; i < entries->arr.size(); i++) { + const CN1Json& e = entries->arr[i]; + if (e.isMap() && e.getNum("date", 0) <= (double) nowMillis) { + current = &e; + } + } + if (current == NULL && !entries->arr.empty() && entries->arr[0].isMap()) { + current = &entries->arr[0]; + } + if (current != NULL) { + state = current->get("state"); + } + } + std::string body = cn1BoardNodeToElement(*layout, state, kindDir, nowMillis, 0); + if (body.empty()) { + return std::string(); + } + return "{\"type\":\"AdaptiveCard\",\"$schema\":\"http://adaptivecards.io/schemas/" + "adaptive-card.json\",\"version\":\"1.5\",\"body\":[" + body + "]}"; +} + +/* ==================================================== the widget provider */ + +/* Tracked live widget instances (widgetId -> kind/size), so the minute timer + * can re-push time-driven content and Deactivate can stop when none remain. */ +struct CN1BoardWidget { + std::string kindId; + std::string size; +}; + +static CRITICAL_SECTION g_boardLock; +static std::map g_boardWidgets; +static HANDLE g_boardShutdownEvent = NULL; +static HANDLE g_boardTimer = NULL; + +static void cn1BoardPushWidget(const winrt::hstring& widgetId, const CN1BoardWidget& info) { + std::string card = cn1BoardBuildCard(info.kindId, info.size); + if (card.empty()) { + return; + } + WidgetUpdateRequestOptions options(widgetId); + options.Template(winrt::hstring(cn1BoardUtf8ToWide(card))); + options.Data(L"{}"); + WidgetManager::GetDefault().UpdateWidget(options); +} + +static void cn1BoardPushAll() { + std::map snapshot; + EnterCriticalSection(&g_boardLock); + snapshot = g_boardWidgets; + LeaveCriticalSection(&g_boardLock); + for (std::map::const_iterator it = snapshot.begin(); + it != snapshot.end(); ++it) { + cn1BoardPushWidget(winrt::hstring(it->first), it->second); + } +} + +/* Minute cadence: Adaptive Cards cannot tick a countdown natively, so while + * any widget is active the provider re-templates + re-pushes every card once + * a minute (a deliberate budget-respecting floor; the in-app floating windows + * tick at full second precision instead). */ +static VOID CALLBACK cn1BoardTimerProc(PVOID, BOOLEAN) { + cn1BoardPushAll(); +} + +static std::string cn1BoardWideToUtf8(const std::wstring& w) { + if (w.empty()) return std::string(); + int n = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int) w.size(), NULL, 0, NULL, NULL); + std::string s((size_t) n, '\0'); + WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int) w.size(), &s[0], n, NULL, NULL); + return s; +} + +/* The board's WidgetSize enum -> the wire-format layout size name. */ +static std::string cn1BoardSizeName(WidgetSize size) { + switch (size) { + case WidgetSize::Small: return "small"; + case WidgetSize::Large: return "large"; + default: return "medium"; + } +} + +struct CN1WidgetProvider : winrt::implements { + void CreateWidget(WidgetContext const& context) { + CN1BoardWidget info; + info.kindId = cn1BoardWideToUtf8(std::wstring(context.DefinitionId())); + info.size = cn1BoardSizeName(context.Size()); + EnterCriticalSection(&g_boardLock); + g_boardWidgets[std::wstring(context.Id())] = info; + LeaveCriticalSection(&g_boardLock); + cn1BoardPushWidget(context.Id(), info); + } + + void DeleteWidget(winrt::hstring const& widgetId, winrt::hstring const&) { + bool empty; + EnterCriticalSection(&g_boardLock); + g_boardWidgets.erase(std::wstring(widgetId)); + empty = g_boardWidgets.empty(); + LeaveCriticalSection(&g_boardLock); + if (empty && g_boardShutdownEvent != NULL) { + SetEvent(g_boardShutdownEvent); + } + } + + void OnActionInvoked(WidgetActionInvokedArgs const& args) { + /* Relay the click to the app: append it to the pending-actions file + * (drained by WindowsWidgetBridge into Surfaces.dispatchAction's + * cold-start queue) and launch the app exe. */ + std::string kindId; + EnterCriticalSection(&g_boardLock); + std::map::const_iterator it = + g_boardWidgets.find(std::wstring(args.WidgetContext().Id())); + if (it != g_boardWidgets.end()) { + kindId = it->second.kindId; + } + LeaveCriticalSection(&g_boardLock); + std::string verb = cn1BoardWideToUtf8(std::wstring(args.Verb())); + if (kindId.empty() || verb.empty()) { + return; + } + std::wstring path = cn1BoardSurfacesRoot() + L"\\pending_actions.txt"; + HANDLE f = CreateFileW(path.c_str(), FILE_APPEND_DATA, FILE_SHARE_READ, NULL, + OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (f != INVALID_HANDLE_VALUE) { + std::string line = kindId + "\t" + verb + "\n"; + DWORD written = 0; + WriteFile(f, line.c_str(), (DWORD) line.size(), &written, NULL); + CloseHandle(f); + } + WCHAR exe[MAX_PATH]; + if (GetModuleFileNameW(NULL, exe, MAX_PATH) > 0) { + ShellExecuteW(NULL, L"open", exe, NULL, NULL, SW_SHOWNORMAL); + } + } + + void OnWidgetContextChanged(WidgetContextChangedArgs const& args) { + /* size / pin-context change: track the new size and re-push */ + WidgetContext ctx = args.WidgetContext(); + CN1BoardWidget info; + bool found = false; + EnterCriticalSection(&g_boardLock); + std::map::iterator it = + g_boardWidgets.find(std::wstring(ctx.Id())); + if (it != g_boardWidgets.end()) { + it->second.size = cn1BoardSizeName(ctx.Size()); + found = true; + info = it->second; + } + LeaveCriticalSection(&g_boardLock); + if (found) { + cn1BoardPushWidget(ctx.Id(), info); + } + } + + void Activate(WidgetContext const& context) { + /* the widget became visible: refresh immediately */ + CN1BoardWidget info; + bool found = false; + EnterCriticalSection(&g_boardLock); + std::map::iterator it = + g_boardWidgets.find(std::wstring(context.Id())); + if (it != g_boardWidgets.end()) { + found = true; + info = it->second; + } + LeaveCriticalSection(&g_boardLock); + if (found) { + cn1BoardPushWidget(context.Id(), info); + } + } + + void Deactivate(winrt::hstring const&) { + /* the widget is off-screen; the minute timer keeps state fresh enough */ + } +}; + +struct CN1WidgetProviderFactory : winrt::implements { + HRESULT __stdcall CreateInstance(IUnknown* outer, GUID const& iid, void** result) noexcept { + if (result == NULL) { + return E_POINTER; + } + *result = NULL; + if (outer != NULL) { + return CLASS_E_NOAGGREGATION; + } + return winrt::make().as(iid, result); + } + + HRESULT __stdcall LockServer(BOOL) noexcept { + return S_OK; + } +}; + +/* ============================================================ entry point */ + +extern "C" { + +/* + * Called from initDisplay (the first native of every launch, before any + * window exists). When the process was activated by COM with + * -RegisterProcessAsComServer this bootstraps the WindowsAppRuntime, registers + * the widget provider class factory and serves the Widgets Board until the + * last widget is deleted (or the board disconnects), then exits the process -- + * the Codename One app UI never starts in this mode. In a normal launch the + * flag is absent and this returns immediately. + */ +void cn1WidgetBoardMaybeRunComServer(void) { + const WCHAR* cmd = GetCommandLineW(); + if (cmd == NULL || wcsstr(cmd, L"-RegisterProcessAsComServer") == NULL) { + return; + } + + InitializeCriticalSection(&g_boardLock); + g_boardShutdownEvent = CreateEventW(NULL, TRUE, FALSE, NULL); + + winrt::init_apartment(); + + /* Bind the installed WindowsAppRuntime (the exe carries no framework + * package dependency of its own). PackageVersion{0} accepts any servicing + * revision of the 1.5 series. Requires the WindowsAppRuntime + * redistributable on the machine -- see the MSIX packaging notes in + * WindowsNativeBuilder. */ + PACKAGE_VERSION minVersion{}; + HRESULT hr = MddBootstrapInitialize(CN1_WINAPPSDK_MAJORMINOR, L"", minVersion); + if (FAILED(hr)) { + cn1WindowsLog("widgetboard: MddBootstrapInitialize failed (is the " + "WindowsAppRuntime installed?)"); + ExitProcess(1); + } + + DWORD cookie = 0; + auto factory = winrt::make(); + hr = CoRegisterClassObject(CN1_WIDGET_PROVIDER_CLSID, factory.get(), + CLSCTX_LOCAL_SERVER, REGCLS_MULTIPLEUSE, &cookie); + if (FAILED(hr)) { + cn1WindowsLog("widgetboard: CoRegisterClassObject failed"); + MddBootstrapShutdown(); + ExitProcess(1); + } + + /* Recover the already-pinned widget set (this process restarts on demand). */ + try { + auto infos = WidgetManager::GetDefault().GetWidgetInfos(); + for (auto const& winfo : infos) { + WidgetContext ctx = winfo.WidgetContext(); + CN1BoardWidget info; + info.kindId = cn1BoardWideToUtf8(std::wstring(ctx.DefinitionId())); + info.size = cn1BoardSizeName(ctx.Size()); + EnterCriticalSection(&g_boardLock); + g_boardWidgets[std::wstring(ctx.Id())] = info; + LeaveCriticalSection(&g_boardLock); + } + } catch (...) { + /* no widgets yet -- CreateWidget will populate the map */ + } + cn1BoardPushAll(); + + /* Minute re-push for dynamic text / interval progress (see the timer + * comment above), on the default threadpool. */ + CreateTimerQueueTimer(&g_boardTimer, NULL, cn1BoardTimerProc, NULL, + 60000, 60000, WT_EXECUTEDEFAULT); + + /* Serve until the last widget is deleted; COM calls arrive on RPC threads, + * so a plain wait suffices (no message pump needed for a non-STA server). */ + WaitForSingleObject(g_boardShutdownEvent, INFINITE); + + if (g_boardTimer != NULL) { + DeleteTimerQueueTimer(NULL, g_boardTimer, NULL); + } + CoRevokeClassObject(cookie); + MddBootstrapShutdown(); + ExitProcess(0); +} + +} /* extern "C" */ + +#endif /* CN1_WIDGETBOARD */ diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_widgets.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_widgets.cpp new file mode 100644 index 00000000000..82d0932304f --- /dev/null +++ b/Ports/WindowsPort/nativeSources/cn1_windows_widgets.cpp @@ -0,0 +1,750 @@ +/* + * 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. + */ + +/* + * Floating layered widget windows: the plain-exe desktop lowering of + * com.codename1.surfaces (home-screen widgets + live activity pill) for the + * native Windows port. Each pinned widget / live activity is a frameless + * WS_POPUP window with WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | + * WS_EX_NOACTIVATE: + * + * - LAYERED + UpdateLayeredWindow with per-pixel alpha: the Java side pushes + * the SurfaceRasterizer's straight-alpha ARGB pixels, this file + * premultiplies them into a 32-bit top-down DIB and blits. Per-pixel alpha + * means the descriptor's rounded corners come for free (transparent pixels + * simply are not part of the window). + * - TOPMOST keeps the widget above normal windows (the desktop analog of a + * home-screen surface); TOOLWINDOW keeps it out of the taskbar/alt-tab; + * NOACTIVATE keeps focus with the user's foreground app. + * + * Threading (the same rules as every other window in this port): + * - Windows belong to the thread that created them, so creation, destruction, + * pixel upload, positioning and hit-rect updates are marshaled to the main + * pump thread by posting WM_CN1_WIDGET to cn1Win.hwnd with a heap-allocated + * op struct the handler frees (the cn1_windows_notify.c pattern; never a + * blocking SendMessage from the EDT). + * - Events travel the other way through a mutex-guarded string queue + * (";click;;" / ";moved;;") that the EDT drains + * via widgetPollEvent, exactly like browserPollEvent's queue. + * + * Interaction: WM_NCHITTEST returns HTCAPTION outside the action hit-rects -- + * so dragging anywhere on the widget body moves the window, courtesy of the + * default caption-drag handling -- and HTCLIENT inside a hit-rect, so a click + * there reaches WM_LBUTTONUP and is queued for Java to resolve against the + * rasterizer's action rectangles. WM_EXITSIZEMOVE reports the final position + * so Java can persist it. + * + * Per-monitor DPI: widgetGetDpiScale exposes the window's current DPI scale + * (GetDpiForWindow, resolved dynamically with a 96-DPI fallback for older + * systems) so Java rasterizes dips at the right pixel size; WM_DPICHANGED + * refreshes the cached scale and queues a "moved" event, prompting Java to + * re-check the scale and re-render. + * + * Rendering is pure Win32/GDI (DIB + UpdateLayeredWindow); this file + * deliberately never touches the shared Direct2D render target, so it cannot + * poison an in-flight BeginDraw/EndDraw batch on the EDT. + */ + +/* Include the SDK/CRT headers BEFORE cn1_windows.h: cn1_globals.h installs + * macros for the bytecode runtime that would otherwise break system headers. + * Deliberately no MSVC STL here -- the xwin cross-compile CI's clang-cl + * predates the Clang version the MSVC STL demands (STL1000), so this file + * sticks to plain C containers (see g_widgetEvents / CN1Widget.hitRects). */ +#ifdef _WIN32 +#include +#include +#include +#include +#include +#include +#include +#endif + +#include "cn1_windows.h" + +#ifdef _WIN32 + +/* --------------------------------------------------------------- op codes */ + +enum { + CN1_WIDGET_OP_CREATE = 1, + CN1_WIDGET_OP_DESTROY = 2, + CN1_WIDGET_OP_SETPIXELS = 3, + CN1_WIDGET_OP_SETPOS = 4, + CN1_WIDGET_OP_SETHITRECTS = 5, + CN1_WIDGET_OP_FOCUSAPP = 6 +}; + +/* widgetSetPosition x sentinel: center the window horizontally on the primary + * work area (real coordinates can be legitimately negative on multi-monitor + * setups, so a plain "x < 0" test would break position restore there). Must + * match WindowsNative.WIDGET_POS_CENTER_H on the Java side. */ +#define CN1_WIDGET_POS_CENTER_H INT_MIN + +/* ------------------------------------------------------------------ state */ + +/* One floating widget window. Allocated by widgetCreate (EDT), freed by the + * OP_DESTROY handler (pump thread). The hwnd is touched only on the pump + * thread; the cached x/y/w/h/dpiScale and the hit-rects are shared between the + * pump thread (writer) and the EDT (reader) under g_widgetLock. */ +struct CN1Widget { + HWND hwnd; + int x; + int y; + int w; /* current pixel size (window == DIB size) */ + int h; + float dpiScale; /* 96 dpi == 1.0 */ + RECT* hitRects; /* action rectangles in client pixels, owned */ + int hitRectCount; + + CN1Widget() : hwnd(NULL), x(0), y(0), w(1), h(1), dpiScale(1.0f), + hitRects(NULL), hitRectCount(0) { + } + + ~CN1Widget() { + free(hitRects); + } +}; + +/* A marshaled operation. Posted as the WM_CN1_WIDGET wParam; the handler owns + * and frees it (and its payloads). Allocated with calloc/free -- only plain + * data crosses the post. */ +typedef struct CN1WidgetOp { + int op; + CN1Widget* widget; + int x; + int y; + int w; + int h; + uint32_t* pixels; /* OP_SETPIXELS: owned, premultiplied BGRA, w*h */ + int* rects; /* OP_SETHITRECTS: owned, packed x,y,w,h per rect */ + int rectCount; +} CN1WidgetOp; + +/* The queue lock also guards every CN1Widget's shared fields. A global C++ + * constructor initializes it before main -- no lazy-init race. */ +struct CN1WidgetLock { + CRITICAL_SECTION cs; + CN1WidgetLock() { + InitializeCriticalSection(&cs); + } +}; +static CN1WidgetLock g_widgetLock; + +/* Outbound event queue, drained by the EDT (widgetPollEvent): a fixed ring of + * malloc'd strings guarded by g_widgetLock. New events are dropped when the + * ring is full (the EDT drains every input pump pass, so 128 pending events + * already means the app is wedged). Plain C on purpose -- no MSVC STL. */ +#define CN1_WIDGET_EVENT_RING 128 +static char* g_widgetEvents[CN1_WIDGET_EVENT_RING]; +static int g_widgetEventHead = 0; /* next slot to read */ +static int g_widgetEventCount = 0; /* filled slots */ + +static bool g_widgetClassRegistered = false; + +/* ---------------------------------------------------------------- helpers */ + +static void cn1WidgetEnqueueEvent(CN1Widget* w, const char* kind, int x, int y) { + char buf[96]; + _snprintf(buf, sizeof(buf), "%lld;%s;%d;%d", (long long) (intptr_t) w, kind, x, y); + buf[sizeof(buf) - 1] = '\0'; + char* copy = _strdup(buf); + if (copy == NULL) { + return; + } + EnterCriticalSection(&g_widgetLock.cs); + if (g_widgetEventCount >= CN1_WIDGET_EVENT_RING) { + LeaveCriticalSection(&g_widgetLock.cs); + free(copy); + return; + } + int tail = (g_widgetEventHead + g_widgetEventCount) % CN1_WIDGET_EVENT_RING; + g_widgetEvents[tail] = copy; + g_widgetEventCount++; + LeaveCriticalSection(&g_widgetLock.cs); +} + +/* GetDpiForWindow shipped with Windows 10 1607; resolve it dynamically so the + * exe still starts on older systems (which then report the 96-DPI fallback). */ +typedef UINT (WINAPI* CN1GetDpiForWindowFn)(HWND); + +static float cn1WidgetDpiScaleFor(HWND hwnd) { + static CN1GetDpiForWindowFn fn = (CN1GetDpiForWindowFn) GetProcAddress( + GetModuleHandleW(L"user32.dll"), "GetDpiForWindow"); + if (fn != NULL && hwnd != NULL) { + UINT dpi = fn(hwnd); + if (dpi > 0) { + return dpi / 96.0f; + } + } + /* Fallback: the (system) screen DPI, or plain 96. */ + HDC dc = GetDC(hwnd); + if (dc != NULL) { + int v = GetDeviceCaps(dc, LOGPIXELSX); + ReleaseDC(hwnd, dc); + if (v > 0) { + return v / 96.0f; + } + } + return 1.0f; +} + +/* True when the client-space point lies inside one of the widget's action + * hit-rects. Called from the widget's WndProc (pump thread). */ +static bool cn1WidgetHitTest(CN1Widget* w, int cx, int cy) { + bool hit = false; + EnterCriticalSection(&g_widgetLock.cs); + for (int i = 0; i < w->hitRectCount; i++) { + const RECT& r = w->hitRects[i]; + if (cx >= r.left && cx < r.right && cy >= r.top && cy < r.bottom) { + hit = true; + break; + } + } + LeaveCriticalSection(&g_widgetLock.cs); + return hit; +} + +/* ------------------------------------------------------- widget WndProc */ + +static LRESULT CALLBACK cn1WidgetWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { + if (msg == WM_NCCREATE) { + /* Wire the CN1Widget* handed through CreateWindowExW's lpParam. */ + CREATESTRUCTW* cs = (CREATESTRUCTW*) lParam; + SetWindowLongPtrW(hwnd, GWLP_USERDATA, (LONG_PTR) cs->lpCreateParams); + return DefWindowProcW(hwnd, msg, wParam, lParam); + } + CN1Widget* w = (CN1Widget*) GetWindowLongPtrW(hwnd, GWLP_USERDATA); + if (w == NULL) { + return DefWindowProcW(hwnd, msg, wParam, lParam); + } + switch (msg) { + case WM_NCHITTEST: { + /* HTCAPTION outside the hit-rects makes the whole widget body a + * drag handle (default caption dragging); HTCLIENT inside a + * hit-rect lets the click reach WM_LBUTTONUP below. */ + POINT pt; + pt.x = GET_X_LPARAM(lParam); + pt.y = GET_Y_LPARAM(lParam); + ScreenToClient(hwnd, &pt); + return cn1WidgetHitTest(w, pt.x, pt.y) ? HTCLIENT : HTCAPTION; + } + case WM_LBUTTONUP: { + /* Only hit-rect areas are HTCLIENT, so this is a click on an + * action region; Java maps the coordinates back to the action. */ + int cx = GET_X_LPARAM(lParam); + int cy = GET_Y_LPARAM(lParam); + if (cn1WidgetHitTest(w, cx, cy)) { + cn1WidgetEnqueueEvent(w, "click", cx, cy); + } + return 0; + } + case WM_MOVE: { + /* Borderless popup: the client origin equals the window origin. */ + EnterCriticalSection(&g_widgetLock.cs); + w->x = (int) (short) LOWORD(lParam); + w->y = (int) (short) HIWORD(lParam); + LeaveCriticalSection(&g_widgetLock.cs); + return 0; + } + case WM_EXITSIZEMOVE: { + /* Drag finished: report the final position for persistence. */ + RECT rc; + if (GetWindowRect(hwnd, &rc)) { + EnterCriticalSection(&g_widgetLock.cs); + w->x = rc.left; + w->y = rc.top; + LeaveCriticalSection(&g_widgetLock.cs); + cn1WidgetEnqueueEvent(w, "moved", rc.left, rc.top); + } + return 0; + } + case WM_DPICHANGED: { + /* Dragged onto a monitor with a different scale: refresh the + * cached scale and nudge Java with a "moved" event -- the bridge + * re-reads the scale and re-renders at the new pixel size (the + * next UpdateLayeredWindow resizes the window to match). */ + UINT dpi = LOWORD(wParam); + RECT rc; + EnterCriticalSection(&g_widgetLock.cs); + if (dpi > 0) { + w->dpiScale = dpi / 96.0f; + } + LeaveCriticalSection(&g_widgetLock.cs); + if (GetWindowRect(hwnd, &rc)) { + cn1WidgetEnqueueEvent(w, "moved", rc.left, rc.top); + } + return 0; + } + case WM_MOUSEACTIVATE: + /* Belt and braces next to WS_EX_NOACTIVATE: interacting with the + * widget never steals focus from the foreground app. */ + return MA_NOACTIVATE; + default: + return DefWindowProcW(hwnd, msg, wParam, lParam); + } +} + +/* ------------------------------------------- pump-thread op implementations */ + +static void cn1WidgetHandleCreate(CN1Widget* w) { + HINSTANCE hInstance = GetModuleHandleW(NULL); + if (!g_widgetClassRegistered) { + WNDCLASSEXW wc; + ZeroMemory(&wc, sizeof(wc)); + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = cn1WidgetWndProc; + wc.hInstance = hInstance; + wc.hCursor = LoadCursorW(NULL, (LPCWSTR) IDC_ARROW); + wc.lpszClassName = L"CodenameOneWidgetWindow"; + RegisterClassExW(&wc); + g_widgetClassRegistered = true; + } + /* Default placement: top-right of the primary work area (mirrors the + * JavaSE floating widgets); Java restores a persisted position right + * after creation when it has one. */ + RECT wa; + wa.left = 0; + wa.top = 0; + wa.right = GetSystemMetrics(SM_CXSCREEN); + wa.bottom = GetSystemMetrics(SM_CYSCREEN); + SystemParametersInfoW(SPI_GETWORKAREA, 0, &wa, 0); + int x = wa.right - w->w - 40; + int y = wa.top + 60; + HWND hwnd = CreateWindowExW( + WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + L"CodenameOneWidgetWindow", L"", WS_POPUP, + x, y, w->w, w->h, NULL, NULL, hInstance, w); + if (hwnd == NULL) { + cn1WindowsLog("widget: CreateWindowExW failed"); + return; + } + float scale = cn1WidgetDpiScaleFor(hwnd); + EnterCriticalSection(&g_widgetLock.cs); + w->hwnd = hwnd; + w->x = x; + w->y = y; + w->dpiScale = scale; + LeaveCriticalSection(&g_widgetLock.cs); + /* A layered window shows nothing until its first UpdateLayeredWindow, so + * showing it "empty" here never flashes. */ + ShowWindow(hwnd, SW_SHOWNOACTIVATE); +} + +/* Blits premultiplied BGRA pixels into the layered window via a 32-bit + * top-down DIB + UpdateLayeredWindow, resizing the window to the pixel block + * (per-pixel alpha -> rounded corners for free). Pump thread. */ +static void cn1WidgetHandleSetPixels(CN1Widget* w, uint32_t* premul, int pw, int ph) { + if (w->hwnd == NULL || premul == NULL || pw <= 0 || ph <= 0) { + return; + } + BITMAPINFO bmi; + ZeroMemory(&bmi, sizeof(bmi)); + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = pw; + bmi.bmiHeader.biHeight = -ph; /* negative height -> top-down rows */ + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + HDC screen = GetDC(NULL); + HDC mem = CreateCompatibleDC(screen); + void* bits = NULL; + HBITMAP dib = CreateDIBSection(mem, &bmi, DIB_RGB_COLORS, &bits, NULL, 0); + if (dib != NULL && bits != NULL) { + /* The 0xAARRGGBB premultiplied ints match the DIB's little-endian + * BGRA byte order exactly, so a straight copy suffices. */ + memcpy(bits, premul, (size_t) pw * (size_t) ph * 4); + HGDIOBJ old = SelectObject(mem, dib); + SIZE size; + size.cx = pw; + size.cy = ph; + POINT src; + src.x = 0; + src.y = 0; + BLENDFUNCTION blend; + blend.BlendOp = AC_SRC_OVER; + blend.BlendFlags = 0; + blend.SourceConstantAlpha = 255; + blend.AlphaFormat = AC_SRC_ALPHA; + if (!UpdateLayeredWindow(w->hwnd, screen, NULL, &size, mem, &src, 0, + &blend, ULW_ALPHA)) { + cn1WindowsLog("widget: UpdateLayeredWindow failed"); + } + SelectObject(mem, old); + } + if (dib != NULL) { + DeleteObject(dib); + } + DeleteDC(mem); + ReleaseDC(NULL, screen); + + EnterCriticalSection(&g_widgetLock.cs); + w->w = pw; + w->h = ph; + LeaveCriticalSection(&g_widgetLock.cs); +} + +static void cn1WidgetHandleSetPos(CN1Widget* w, int x, int y) { + if (w->hwnd == NULL) { + return; + } + if (x == CN1_WIDGET_POS_CENTER_H) { + RECT wa; + wa.left = 0; + wa.top = 0; + wa.right = GetSystemMetrics(SM_CXSCREEN); + wa.bottom = GetSystemMetrics(SM_CYSCREEN); + SystemParametersInfoW(SPI_GETWORKAREA, 0, &wa, 0); + int width; + EnterCriticalSection(&g_widgetLock.cs); + width = w->w; + LeaveCriticalSection(&g_widgetLock.cs); + x = wa.left + ((wa.right - wa.left) - width) / 2; + } + SetWindowPos(w->hwnd, NULL, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); + EnterCriticalSection(&g_widgetLock.cs); + w->x = x; + w->y = y; + LeaveCriticalSection(&g_widgetLock.cs); +} + +static void cn1WidgetHandleSetHitRects(CN1Widget* w, const int* rects, int rectCount) { + RECT* copy = NULL; + if (rectCount > 0) { + copy = (RECT*) calloc((size_t) rectCount, sizeof(RECT)); + if (copy == NULL) { + return; + } + for (int i = 0; i < rectCount; i++) { + copy[i].left = rects[i * 4]; + copy[i].top = rects[i * 4 + 1]; + copy[i].right = copy[i].left + rects[i * 4 + 2]; + copy[i].bottom = copy[i].top + rects[i * 4 + 3]; + } + } + EnterCriticalSection(&g_widgetLock.cs); + free(w->hitRects); + w->hitRects = copy; + w->hitRectCount = copy == NULL ? 0 : rectCount; + LeaveCriticalSection(&g_widgetLock.cs); +} + +static void cn1WidgetHandleDestroy(CN1Widget* w) { + HWND hwnd = w->hwnd; + if (hwnd != NULL) { + /* Detach the struct first so late messages during destruction fall + * through to DefWindowProc instead of touching freed memory. */ + SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); + DestroyWindow(hwnd); + } + delete w; +} + +static void cn1WidgetHandleFocusApp(void) { + if (cn1Win.hwnd == NULL) { + return; + } + if (IsIconic(cn1Win.hwnd)) { + ShowWindow(cn1Win.hwnd, SW_RESTORE); + } + SetForegroundWindow(cn1Win.hwnd); +} + +/* Forwarded from cn1WinWndProc (pump thread) for WM_CN1_WIDGET. Owns and frees + * the posted op struct and its payloads. */ +void cn1WinWidgetHandleMessage(WPARAM wParam) { + CN1WidgetOp* op = (CN1WidgetOp*) wParam; + if (op == NULL) { + return; + } + switch (op->op) { + case CN1_WIDGET_OP_CREATE: + cn1WidgetHandleCreate(op->widget); + break; + case CN1_WIDGET_OP_SETPIXELS: + cn1WidgetHandleSetPixels(op->widget, op->pixels, op->w, op->h); + break; + case CN1_WIDGET_OP_SETPOS: + cn1WidgetHandleSetPos(op->widget, op->x, op->y); + break; + case CN1_WIDGET_OP_SETHITRECTS: + cn1WidgetHandleSetHitRects(op->widget, op->rects, op->rectCount); + break; + case CN1_WIDGET_OP_DESTROY: + cn1WidgetHandleDestroy(op->widget); + break; + case CN1_WIDGET_OP_FOCUSAPP: + cn1WidgetHandleFocusApp(); + break; + default: + break; + } + if (op->pixels != NULL) { + free(op->pixels); + } + if (op->rects != NULL) { + free(op->rects); + } + free(op); +} + +/* --------------------------------------------------------- Java bridge */ + +/* Posts an op to the pump thread; frees it (and payloads) on post failure so + * nothing leaks when the window is gone. EDT side. */ +static void cn1WidgetPostOp(CN1WidgetOp* op) { + if (cn1Win.hwnd == NULL + || !PostMessageW(cn1Win.hwnd, WM_CN1_WIDGET, (WPARAM) op, 0)) { + if (op->op == CN1_WIDGET_OP_CREATE || op->op == CN1_WIDGET_OP_DESTROY) { + delete op->widget; + } + if (op->pixels != NULL) { + free(op->pixels); + } + if (op->rects != NULL) { + free(op->rects); + } + free(op); + } +} + +/* Premultiplies one straight-alpha ARGB pixel (the CN1 getRGB layout) for + * UpdateLayeredWindow's AC_SRC_ALPHA blend. Rounded, so a full ramp + * round-trips visually cleanly. */ +static uint32_t cn1WidgetPremultiply(uint32_t argb) { + uint32_t a = (argb >> 24) & 0xff; + if (a == 255) { + return argb; + } + if (a == 0) { + return 0; + } + uint32_t r = ((((argb >> 16) & 0xff) * a) + 127) / 255; + uint32_t g = ((((argb >> 8) & 0xff) * a) + 127) / 255; + uint32_t b = (((argb & 0xff) * a) + 127) / 255; + return (a << 24) | (r << 16) | (g << 8) | b; +} + +extern "C" { + +JAVA_LONG com_codename1_impl_windows_WindowsNative_widgetCreate___int_int_R_long( + CODENAME_ONE_THREAD_STATE, JAVA_INT w, JAVA_INT h) { + if (cn1Win.hwnd == NULL) { + return 0; /* headless: no pump thread to own the window */ + } + CN1Widget* widget = new CN1Widget(); + widget->w = w > 0 ? w : 1; + widget->h = h > 0 ? h : 1; + CN1WidgetOp* op = (CN1WidgetOp*) calloc(1, sizeof(CN1WidgetOp)); + if (op == NULL) { + delete widget; + return 0; + } + op->op = CN1_WIDGET_OP_CREATE; + op->widget = widget; + if (!PostMessageW(cn1Win.hwnd, WM_CN1_WIDGET, (WPARAM) op, 0)) { + delete widget; + free(op); + return 0; + } + return (JAVA_LONG) (intptr_t) widget; +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_widgetUpdatePixels___long_int_1ARRAY_int_int( + CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_OBJECT argb, JAVA_INT w, JAVA_INT h) { + CN1Widget* widget = (CN1Widget*) (intptr_t) peer; + if (widget == NULL || argb == JAVA_NULL || w <= 0 || h <= 0) { + return; + } + JAVA_ARRAY_INT* data = (JAVA_ARRAY_INT*) (*(JAVA_ARRAY) argb).data; + int len = (*(JAVA_ARRAY) argb).length; + if (len < w * h) { + return; + } + /* Copy + premultiply here (EDT) so the posted payload no longer references + * the Java array -- the GC may move/collect it before the pump thread runs. */ + uint32_t* premul = (uint32_t*) malloc((size_t) w * (size_t) h * 4); + if (premul == NULL) { + return; + } + for (int i = 0; i < w * h; i++) { + premul[i] = cn1WidgetPremultiply((uint32_t) data[i]); + } + CN1WidgetOp* op = (CN1WidgetOp*) calloc(1, sizeof(CN1WidgetOp)); + if (op == NULL) { + free(premul); + return; + } + op->op = CN1_WIDGET_OP_SETPIXELS; + op->widget = widget; + op->pixels = premul; + op->w = w; + op->h = h; + cn1WidgetPostOp(op); +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_widgetSetPosition___long_int_int( + CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_INT x, JAVA_INT y) { + CN1Widget* widget = (CN1Widget*) (intptr_t) peer; + if (widget == NULL) { + return; + } + CN1WidgetOp* op = (CN1WidgetOp*) calloc(1, sizeof(CN1WidgetOp)); + if (op == NULL) { + return; + } + op->op = CN1_WIDGET_OP_SETPOS; + op->widget = widget; + op->x = x; + op->y = y; + cn1WidgetPostOp(op); +} + +JAVA_INT com_codename1_impl_windows_WindowsNative_widgetGetX___long_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_LONG peer) { + CN1Widget* widget = (CN1Widget*) (intptr_t) peer; + if (widget == NULL) { + return 0; + } + EnterCriticalSection(&g_widgetLock.cs); + int x = widget->x; + LeaveCriticalSection(&g_widgetLock.cs); + return x; +} + +JAVA_INT com_codename1_impl_windows_WindowsNative_widgetGetY___long_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_LONG peer) { + CN1Widget* widget = (CN1Widget*) (intptr_t) peer; + if (widget == NULL) { + return 0; + } + EnterCriticalSection(&g_widgetLock.cs); + int y = widget->y; + LeaveCriticalSection(&g_widgetLock.cs); + return y; +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_widgetSetHitRects___long_int_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_OBJECT rects) { + CN1Widget* widget = (CN1Widget*) (intptr_t) peer; + if (widget == NULL) { + return; + } + int* copy = NULL; + int rectCount = 0; + if (rects != JAVA_NULL) { + JAVA_ARRAY_INT* data = (JAVA_ARRAY_INT*) (*(JAVA_ARRAY) rects).data; + int len = (*(JAVA_ARRAY) rects).length; + rectCount = len / 4; + if (rectCount > 0) { + copy = (int*) malloc((size_t) rectCount * 4 * sizeof(int)); + if (copy == NULL) { + return; + } + memcpy(copy, data, (size_t) rectCount * 4 * sizeof(int)); + } + } + CN1WidgetOp* op = (CN1WidgetOp*) calloc(1, sizeof(CN1WidgetOp)); + if (op == NULL) { + if (copy != NULL) { + free(copy); + } + return; + } + op->op = CN1_WIDGET_OP_SETHITRECTS; + op->widget = widget; + op->rects = copy; + op->rectCount = rectCount; + cn1WidgetPostOp(op); +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_widgetDestroy___long( + CODENAME_ONE_THREAD_STATE, JAVA_LONG peer) { + CN1Widget* widget = (CN1Widget*) (intptr_t) peer; + if (widget == NULL) { + return; + } + CN1WidgetOp* op = (CN1WidgetOp*) calloc(1, sizeof(CN1WidgetOp)); + if (op == NULL) { + return; + } + op->op = CN1_WIDGET_OP_DESTROY; + op->widget = widget; + cn1WidgetPostOp(op); +} + +/* Next queued widget event (";click;;" / ";moved;;"), + * or null when none. Drained by the EDT in WindowsImplementation.drainInput. */ +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_widgetPollEvent___R_java_lang_String( + CODENAME_ONE_THREAD_STATE) { + char* ev; + EnterCriticalSection(&g_widgetLock.cs); + if (g_widgetEventCount == 0) { + LeaveCriticalSection(&g_widgetLock.cs); + return JAVA_NULL; + } + ev = g_widgetEvents[g_widgetEventHead]; + g_widgetEvents[g_widgetEventHead] = NULL; + g_widgetEventHead = (g_widgetEventHead + 1) % CN1_WIDGET_EVENT_RING; + g_widgetEventCount--; + LeaveCriticalSection(&g_widgetLock.cs); + JAVA_OBJECT str = newStringFromCString(threadStateData, ev); + free(ev); + return str; +} + +/* The widget window's current DPI scale (96 dpi == 1.0); peer 0 reports the + * main window's scale (the best pre-creation estimate). */ +JAVA_FLOAT com_codename1_impl_windows_WindowsNative_widgetGetDpiScale___long_R_float( + CODENAME_ONE_THREAD_STATE, JAVA_LONG peer) { + CN1Widget* widget = (CN1Widget*) (intptr_t) peer; + if (widget == NULL) { + return cn1WidgetDpiScaleFor(cn1Win.hwnd); + } + EnterCriticalSection(&g_widgetLock.cs); + float scale = widget->dpiScale; + LeaveCriticalSection(&g_widgetLock.cs); + return scale; +} + +/* Brings the main app window to the foreground (widget click -> open app). + * Marshaled to the pump thread like every other window operation. */ +JAVA_VOID com_codename1_impl_windows_WindowsNative_widgetFocusApp__( + CODENAME_ONE_THREAD_STATE) { + if (cn1Win.hwnd == NULL) { + return; + } + CN1WidgetOp* op = (CN1WidgetOp*) calloc(1, sizeof(CN1WidgetOp)); + if (op == NULL) { + return; + } + op->op = CN1_WIDGET_OP_FOCUSAPP; + if (!PostMessageW(cn1Win.hwnd, WM_CN1_WIDGET, (WPARAM) op, 0)) { + free(op); + } +} + +} /* extern "C" */ + +#endif /* _WIN32 */ diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp index a474397b938..5f7498eb9ba 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp @@ -601,6 +601,13 @@ LRESULT CALLBACK cn1WinWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam * while the printing worker blocks in SendMessage * (cn1_windows_print.cpp). */ return cn1WinPrintDialogHandleMessage(wParam); + case WM_CN1_WIDGET: + /* Floating widget window op (create/pixels/pos/hit-rects/destroy) + * marshaled from the EDT (cn1_windows_widgets.cpp). The widget + * windows have their own WndProc; this main-window case only hosts + * the op dispatch because ops arrive before their windows exist. */ + cn1WinWidgetHandleMessage(wParam); + return 0; case WM_CTLCOLOREDIT: { /* Colour the native edit overlay to match the CN1 field it stands in * for; fall through to default when it is not our control. */ @@ -692,6 +699,15 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_faultSelfTestEnabled___R_b JAVA_VOID com_codename1_impl_windows_WindowsNative_initDisplay___java_lang_String_int_int( CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1Arg1, JAVA_INT __cn1Arg2, JAVA_INT __cn1Arg3) { +#ifdef CN1_WIDGETBOARD + /* MSIX Widgets Board activation: when Windows launched this exe with + * -RegisterProcessAsComServer it wants the out-of-process widget provider, + * not the app UI. The check lives here (the first native call of every + * launch, before any window exists) because the process entry point is the + * translator-generated C main, which is not in nativeSources. This call + * never returns in server mode -- it serves widgets and exits the process. */ + cn1WidgetBoardMaybeRunComServer(); +#endif if (InterlockedCompareExchange(&cn1Win.initialized, 1, 0) != 0) { return; /* already initialised */ } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 11e73024e38..e9237c9d99c 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -690,6 +690,36 @@ private void drainInput() { if (clicked != null) { dispatchLocalNotification(clicked); } + // Floating widget window events (clicks inside action hit-rects, drag + // moves) are queued on the pump thread by cn1_windows_widgets.cpp and + // drained here alongside every other input queue. The bridge routes + // clicks through Surfaces.dispatchAction, which marshals to the EDT. + if (surfaceBridge != null) { + String widgetEvent = WindowsNative.widgetPollEvent(); + while (widgetEvent != null) { + surfaceBridge.handleNativeEvent(widgetEvent); + widgetEvent = WindowsNative.widgetPollEvent(); + } + } + } + + /* ------------------------------------------------------ external surfaces */ + + // volatile: created lazily on the EDT (getSurfaceBridge), read by the main + // pump thread inside drainInput + private volatile WindowsWidgetBridge surfaceBridge; + + /** + * The Windows lowering of {@code com.codename1.surfaces}: floating layered + * widget windows plus a live activity pill (see {@link WindowsWidgetBridge}). + * Lazy so apps that never touch the surfaces API pay nothing. + */ + @Override + public com.codename1.surfaces.spi.SurfaceBridge getSurfaceBridge() { + if (surfaceBridge == null) { + surfaceBridge = new WindowsWidgetBridge(); + } + return surfaceBridge; } /* --------------------------------------------------- local notifications diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index c0bc766ff92..68d5a5c3476 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -514,6 +514,82 @@ public static native long editStringAt(int x, int y, int w, int h, String text, */ public static native String notificationPollClicked(); + /* ------------------------------------------------ floating widget windows */ + + /** + * {@code x} sentinel for {@link #widgetSetPosition}: center the widget + * window horizontally on the primary work area (used by the live activity + * pill). A sentinel rather than a sign test because real window coordinates + * are legitimately negative on multi-monitor setups. Mirrors + * {@code CN1_WIDGET_POS_CENTER_H} in cn1_windows_widgets.cpp. + */ + public static final int WIDGET_POS_CENTER_H = Integer.MIN_VALUE; + + /** + * Creates a floating layered widget window (WS_POPUP + WS_EX_LAYERED | + * TOPMOST | TOOLWINDOW | NOACTIVATE) of the given pixel size -- the desktop + * lowering of {@code com.codename1.surfaces}. Creation is marshaled to the + * window-owning pump thread (WM_CN1_WIDGET); the window shows nothing until + * the first {@link #widgetUpdatePixels} blit. Default placement is the + * top-right of the primary work area. Returns an opaque peer, or 0 in + * headless mode. + */ + public static native long widgetCreate(int w, int h); + + /** + * Pushes {@code w*h} straight-alpha ARGB pixels (the CN1 getRGB layout, + * typically a {@code SurfaceRasterizer} result) into the widget window. The + * pixels are premultiplied into a 32-bit DIB and blitted via + * UpdateLayeredWindow with per-pixel alpha, which also resizes the window + * to {@code w x h} -- so a DPI change is handled by simply re-rendering at + * the new pixel size. Transparent pixels are not part of the window, giving + * rounded corners for free. + */ + public static native void widgetUpdatePixels(long peer, int[] argb, int w, int h); + + /** + * Moves the widget window to the given screen position; + * {@code x ==} {@link #WIDGET_POS_CENTER_H} centers it horizontally on the + * primary work area. + */ + public static native void widgetSetPosition(long peer, int x, int y); + + /** The widget window's current screen x (for position persistence). */ + public static native int widgetGetX(long peer); + + /** The widget window's current screen y (for position persistence). */ + public static native int widgetGetY(long peer); + + /** + * Sets the action hit rectangles in window (client) pixels, packed as + * {@code [x,y,w,h, x,y,w,h, ...]}. Clicks inside a hit-rect are queued as + * events ({@link #widgetPollEvent}); everywhere else the widget body is a + * drag handle (WM_NCHITTEST returns HTCAPTION). + */ + public static native void widgetSetHitRects(long peer, int[] rects); + + /** Destroys the widget window and frees the peer (marshaled; do not reuse the peer). */ + public static native void widgetDestroy(long peer); + + /** + * Next queued widget event, or {@code null} when none: + * {@code ";click;;"} (a click inside a hit-rect, client pixels) + * or {@code ";moved;;"} (drag finished / DPI changed, screen + * position). Drained by the EDT in {@code drainInput}, mirroring + * {@link #browserPollEvent}. + */ + public static native String widgetPollEvent(); + + /** + * The widget window's current per-monitor DPI scale (96 dpi == 1.0), so + * the bridge rasterizes dips at the right pixel size; peer 0 reports the + * main window's scale as the pre-creation estimate. + */ + public static native float widgetGetDpiScale(long peer); + + /** Brings the main app window to the foreground (widget click routing). */ + public static native void widgetFocusApp(); + /** * Shows a modal native file dialog and returns the chosen path, or * {@code null} on cancel (or in headless mode). {@code type} mirrors the diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsWidgetBridge.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsWidgetBridge.java new file mode 100644 index 00000000000..fe9de01ef6b --- /dev/null +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsWidgetBridge.java @@ -0,0 +1,1073 @@ +/* + * 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.windows; + +import com.codename1.io.JSONParser; +import com.codename1.io.Preferences; +import com.codename1.surfaces.SurfaceRasterizer; +import com.codename1.surfaces.Surfaces; +import com.codename1.surfaces.spi.SurfaceBridge; +import com.codename1.ui.Display; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.Timer; +import java.util.TimerTask; + +/** + * The native Windows {@link SurfaceBridge}: the plain-exe desktop face of + * {@code com.codename1.surfaces}. Every pinned widget kind gets a frameless, + * always-on-top layered window (created through the {@code widget*} natives in + * {@link WindowsNative} / {@code cn1_windows_widgets.cpp}) that renders the + * kind's published timeline with {@link SurfaceRasterizer} -- the Windows + * equivalent of a home-screen widget, mirroring the JavaSE port's + * {@code JavaSEWidgetWindows}. A running live activity docks a black pill + * window at the top-center of the primary work area. + * + *

Everything published through this bridge is kept in memory and + * persisted to disk (under {@code \cn1surfaces\\} -- + * timeline.json plus content-hash-named PNGs, identical layout to the JavaSE + * bridge and to what the MSIX Widgets Board provider reads), honoring the + * dead-process rule of the surfaces SPI. Live activities are transient and are + * not persisted. The pinned widget set and window geometry persist in + * {@link Preferences} ({@code cn1.surfaces.pinned} / {@code cn1.surfaces.geom.}) + * so widgets restore where the user left them on the next launch; widgets are + * process-bound in this version.

+ * + *

Threading: rasterization always runs on the Codename One EDT (a + * {@code SurfaceRasterizer} requirement); the pixels are then pushed through + * the natives, which marshal all window work to the Win32 pump thread + * themselves. Native events (clicks inside action hit-rects, drag moves) + * arrive on the pump thread via {@code widgetPollEvent}, drained by + * {@code WindowsImplementation.drainInput}, and are routed through + * {@code Surfaces.dispatchAction} which handles EDT marshaling.

+ */ +public class WindowsWidgetBridge implements SurfaceBridge { + private static final String[] SIZE_NAMES = {"small", "medium", "large"}; + private static final int[] SIZE_W = {158, 338, 338}; + private static final int[] SIZE_H = {158, 158, 354}; + private static final int CORNER = 24; + private static final int PILL_W = 250; + private static final int PILL_H = 36; + private static final int PILL_MARGIN_TOP = 8; + private static final long LINGER_MILLIS = 3000; + private static final String PREF_PINNED = "cn1.surfaces.pinned"; + private static final String PREF_GEOM = "cn1.surfaces.geom."; + + /** A floating surface window: a pinned widget kind or the live activity pill. */ + private final class SurfaceWindow { + final String kindId; // null for the live activity pill + final String activityId; // null for widget windows + String sizeName; + long handle; + float scale = 1; + // last rendered action rectangles, in window pixels; written on the EDT, + // read on the pump thread when a click event arrives + List actions = + new ArrayList(); + TimerTask refreshTask; + boolean closed; + + SurfaceWindow(String kindId, String activityId, String sizeName) { + this.kindId = kindId; + this.activityId = activityId; + this.sizeName = sizeName; + } + } + + /** A running live activity: parsed descriptor, images and the latest state. */ + private static final class ActivityRecord { + final Map descriptor; + final Map images; + Map state; + + ActivityRecord(Map descriptor, Map images, + Map state) { + this.descriptor = descriptor; + this.images = images; + this.state = state; + } + } + + private final String root; + private final Map> kinds = + new LinkedHashMap>(); + private final Map> timelines = + new HashMap>(); + private final Map> kindImages = + new HashMap>(); + private final Map liveActivities = + new LinkedHashMap(); + private final Map pinned = + new LinkedHashMap(); + private final Map byHandle = new HashMap(); + private SurfaceWindow pill; + private int nextActivityId = 1; + private final Timer timer = new Timer(); + + /** + * Creates the bridge, restores timelines persisted by a previous run, + * re-pins the widgets the user had pinned, and delivers any actions the + * MSIX Widgets Board provider queued for a cold start. + */ + public WindowsWidgetBridge() { + String storage = WindowsNative.storageDir(); + this.root = (storage == null ? "." : storage) + "\\cn1surfaces"; + WindowsNative.fileMkdir(root); + restoreFromDisk(); + drainBoardActions(); + restorePinned(); + } + + // --- SurfaceBridge ----------------------------------------------------------- + + @Override + public boolean areWidgetsSupported() { + return true; + } + + @Override + public boolean isLiveActivitySupported() { + return true; + } + + @Override + public void registerWidgetKind(String kindJson) { + Map kind = parse(kindJson); + if (kind == null || !(kind.get("id") instanceof String)) { + return; + } + String kindId = (String) kind.get("id"); + synchronized (this) { + kinds.put(kindId, kind); + } + writeFileSafely(kindDir(kindId) + "\\kind.json", utf8(kindJson)); + } + + @Override + public void publishWidgetTimeline(String kindId, String timelineJson, + Map images) { + Map doc = parse(timelineJson); + if (doc == null || kindId == null) { + return; + } + String dir = kindDir(kindId); + // The document's images list is the COMPLETE reference set (the serializer includes + // registered-name references to blobs shipped earlier). Resolve every referenced blob + // (new bytes, previous in-memory map, disk) so registered-name references keep + // rendering, and drop everything unreferenced. + Set referenced = referencedImageNames(doc); + Map imageCopy = new HashMap(); + SurfaceWindow window; + synchronized (this) { + Map previous = kindImages.get(kindId); + for (String name : referenced) { + byte[] data = images != null ? images.get(name) : null; + if (data == null && previous != null) { + data = previous.get(name); + } + if (data == null) { + data = readFile(dir + "\\" + name + ".png"); + } + if (data != null) { + imageCopy.put(name, data); + } + } + timelines.put(kindId, doc); + kindImages.put(kindId, imageCopy); + window = pinned.get(kindId); + } + writeFileSafely(dir + "\\timeline.json", utf8(timelineJson)); + for (Map.Entry e : imageCopy.entrySet()) { + String png = dir + "\\" + e.getKey() + ".png"; + if (!WindowsNative.fileExists(png)) { + // image names are content hashes so an existing file never needs rewriting + writeFileSafely(png, e.getValue()); + } + } + // garbage collect blobs the replacement timeline no longer references: content-hash + // names would otherwise accumulate without bound for frequently changing art + String[] files = WindowsNative.fileList(dir); + if (files != null) { + for (String name : files) { + if (name != null && name.endsWith(".png") + && !referenced.contains(name.substring(0, name.length() - 4))) { + WindowsNative.fileDelete(dir + "\\" + name); + } + } + } + if (window != null) { + requestRender(window); + } + } + + /// The names in the parsed timeline document's `images` list. + private static Set referencedImageNames(Map doc) { + Set referenced = new HashSet(); + Object names = doc.get("images"); + if (names instanceof java.util.Collection) { + for (Object o : (java.util.Collection) names) { + referenced.add(String.valueOf(o)); + } + } + return referenced; + } + + @Override + public void reloadWidgets(String kindId) { + List windows; + synchronized (this) { + windows = new ArrayList(pinned.values()); + } + for (SurfaceWindow w : windows) { + if (kindId == null || kindId.equals(w.kindId)) { + requestRender(w); + } + } + } + + @Override + public synchronized int getInstalledWidgetCount(String kindId) { + return pinned.containsKey(kindId) ? 1 : 0; + } + + @Override + public String startLiveActivity(String descriptorJson, Map images) { + Map doc = parse(descriptorJson); + if (doc == null) { + return null; + } + Map imageCopy = new HashMap(); + if (images != null) { + imageCopy.putAll(images); + } + String id; + SurfaceWindow window; + synchronized (this) { + id = "la" + nextActivityId++; + liveActivities.put(id, new ActivityRecord(doc, imageCopy, asMap(doc.get("state")))); + if (pill != null) { + closeWindow(pill); + } + window = new SurfaceWindow(null, id, null); + pill = window; + } + float scale = WindowsNative.widgetGetDpiScale(0); + if (scale <= 0) { + scale = 1; + } + window.scale = scale; + window.handle = WindowsNative.widgetCreate(Math.round(PILL_W * scale), + Math.round(PILL_H * scale)); + if (window.handle == 0) { + synchronized (this) { + pill = null; + liveActivities.remove(id); + } + return null; + } + synchronized (this) { + byHandle.put(Long.valueOf(window.handle), window); + } + WindowsNative.widgetSetPosition(window.handle, WindowsNative.WIDGET_POS_CENTER_H, + Math.round(PILL_MARGIN_TOP * scale)); + requestRender(window); + return id; + } + + @Override + public void updateLiveActivity(String activityId, String stateJson) { + ActivityRecord rec; + SurfaceWindow window; + synchronized (this) { + rec = liveActivities.get(activityId); + window = pill != null && activityId != null && activityId.equals(pill.activityId) + ? pill : null; + } + if (rec == null) { + return; + } + Map state = parse(stateJson); + rec.state = state == null ? new HashMap() : state; + if (window != null) { + requestRender(window); + } + } + + @Override + public void endLiveActivity(final String activityId, String finalStateJson, + boolean dismissImmediately) { + ActivityRecord rec; + final SurfaceWindow window; + synchronized (this) { + rec = liveActivities.get(activityId); + if (pill != null && activityId != null && activityId.equals(pill.activityId)) { + window = pill; + pill = null; + } else { + window = null; + } + if (window == null || dismissImmediately) { + liveActivities.remove(activityId); + } + } + if (window == null) { + return; + } + if (dismissImmediately) { + closeWindow(window); + return; + } + // show the final state briefly before dismissing, like the platforms do; + // the record stays registered until the linger ends so the async render + // can still resolve it + if (rec != null && finalStateJson != null) { + Map state = parse(finalStateJson); + if (state != null) { + rec.state = state; + requestRender(window); + } + } + timer.schedule(new TimerTask() { + @Override + public void run() { + synchronized (WindowsWidgetBridge.this) { + liveActivities.remove(activityId); + } + closeWindow(window); + } + }, LINGER_MILLIS); + } + + // --- pin management ------------------------------------------------------------ + + /** + * Pins a floating widget for a kind (one instance per kind), creating its + * native layered window sized {@code small}/{@code medium}/{@code large} + * dips scaled by the monitor's DPI. Re-pinning with a different size + * re-renders at the new size. Any thread. + * + * @param kindId the widget kind to pin + * @param sizeName {@code small} / {@code medium} / {@code large}, null for small + */ + public void pinWidget(String kindId, String sizeName) { + if (kindId == null) { + return; + } + String size = normalizeSize(sizeName); + SurfaceWindow window; + boolean create = false; + synchronized (this) { + window = pinned.get(kindId); + if (window == null) { + window = new SurfaceWindow(kindId, null, size); + pinned.put(kindId, window); + create = true; + } else if (sizeName != null) { + window.sizeName = size; + } + } + if (create) { + float scale = WindowsNative.widgetGetDpiScale(0); + if (scale <= 0) { + scale = 1; + } + window.scale = scale; + int index = sizeIndex(size); + window.handle = WindowsNative.widgetCreate(Math.round(SIZE_W[index] * scale), + Math.round(SIZE_H[index] * scale)); + if (window.handle == 0) { + synchronized (this) { + pinned.remove(kindId); + } + return; + } + synchronized (this) { + byHandle.put(Long.valueOf(window.handle), window); + } + // restore the persisted position, if the user placed this kind before + String geom = Preferences.get(PREF_GEOM + kindId, ""); + int comma = geom.indexOf(','); + if (comma > 0) { + try { + WindowsNative.widgetSetPosition(window.handle, + Integer.parseInt(geom.substring(0, comma)), + Integer.parseInt(geom.substring(comma + 1))); + } catch (NumberFormatException ignore) { + // stale preference; keep the native default placement + } + } + } + savePinned(); + requestRender(window); + } + + /** + * Removes a pinned widget window. Any thread. + * + * @param kindId the widget kind to unpin + */ + public void unpinWidget(String kindId) { + SurfaceWindow window; + synchronized (this) { + window = pinned.remove(kindId); + } + if (window != null) { + closeWindow(window); + savePinned(); + } + } + + private void restorePinned() { + String list = Preferences.get(PREF_PINNED, ""); + if (list.length() == 0) { + return; + } + for (String entry : split(list, ';')) { + int sep = entry.indexOf('|'); + if (sep > 0) { + pinWidget(entry.substring(0, sep), normalizeSize(entry.substring(sep + 1))); + } + } + } + + private void savePinned() { + StringBuilder sb = new StringBuilder(); + synchronized (this) { + for (SurfaceWindow w : pinned.values()) { + if (sb.length() > 0) { + sb.append(';'); + } + sb.append(w.kindId).append('|').append(w.sizeName); + } + } + Preferences.set(PREF_PINNED, sb.toString()); + } + + private void closeWindow(SurfaceWindow window) { + final long handle; + synchronized (this) { + if (window.closed) { + return; + } + window.closed = true; + handle = window.handle; + if (handle != 0) { + byHandle.remove(Long.valueOf(handle)); + } + if (window.refreshTask != null) { + window.refreshTask.cancel(); + window.refreshTask = null; + } + } + if (handle != 0 && Display.isInitialized()) { + // destroy from the EDT so it serializes behind any in-flight render: + // a renderOnEdt that already passed its closed check must post its + // pixel op before the destroy op, or the native handler would touch + // a freed widget struct + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + WindowsNative.widgetDestroy(handle); + } + }); + } else if (handle != 0) { + WindowsNative.widgetDestroy(handle); + } + } + + // --- native event routing -------------------------------------------------------- + + /** + * Routes one native widget event drained by + * {@code WindowsImplementation.drainInput} (pump thread): + * {@code ";click;;"} focuses the app and dispatches the + * matching action through {@code Surfaces.dispatchAction} (which marshals + * to the EDT itself); {@code ";moved;;"} persists the new + * position and re-renders when the monitor's DPI scale changed. + * + * @param event the encoded native event + */ + void handleNativeEvent(String event) { + if (event == null) { + return; + } + String[] parts = split(event, ';'); + if (parts.length < 4) { + return; + } + long handle; + int x; + int y; + try { + handle = Long.parseLong(parts[0]); + x = Integer.parseInt(parts[2]); + y = Integer.parseInt(parts[3]); + } catch (NumberFormatException err) { + return; + } + SurfaceWindow window; + synchronized (this) { + window = byHandle.get(Long.valueOf(handle)); + } + if (window == null) { + return; + } + if ("click".equals(parts[1])) { + SurfaceRasterizer.ActionRect match = null; + synchronized (this) { + for (SurfaceRasterizer.ActionRect r : window.actions) { + if (x >= r.getX() && x < r.getX() + r.getWidth() + && y >= r.getY() && y < r.getY() + r.getHeight()) { + match = r; + break; + } + } + } + if (match != null) { + WindowsNative.widgetFocusApp(); + // Surfaces.dispatchAction marshals to the CN1 EDT itself + Surfaces.dispatchAction(actionSource(window), match.getActionId(), + match.getParams()); + } + } else if ("moved".equals(parts[1])) { + if (window.kindId != null) { + final String key = PREF_GEOM + window.kindId; + final String value = x + "," + y; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + Preferences.set(key, value); + } + }); + } + // dragged onto a monitor with a different scale: re-render at the new + // pixel size (requestRender re-reads the scale) + float scale = WindowsNative.widgetGetDpiScale(handle); + if (scale > 0 && Math.abs(scale - window.scale) > 0.01f) { + requestRender(window); + } + } + } + + private String actionSource(SurfaceWindow window) { + if (window.kindId != null) { + return window.kindId; + } + ActivityRecord rec; + synchronized (this) { + rec = liveActivities.get(window.activityId); + } + Object type = rec == null ? null : rec.descriptor.get("type"); + return type instanceof String ? (String) type : window.activityId; + } + + // --- rendering ------------------------------------------------------------------- + + /** + * Re-renders a surface window: rasterizes on the EDT (a SurfaceRasterizer + * requirement), composes the rounded platform background, pushes pixels + + * hit-rects through the natives and schedules the next time-driven + * re-render (dynamic text tick / timeline entry flip). Any thread. + */ + private void requestRender(final SurfaceWindow window) { + if (!Display.isInitialized()) { + return; + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + renderOnEdt(window); + } + }); + } + + private void renderOnEdt(SurfaceWindow window) { + long handle; + synchronized (this) { + if (window.closed) { + return; + } + handle = window.handle; + } + if (handle == 0) { + return; + } + float scale = WindowsNative.widgetGetDpiScale(handle); + if (scale <= 0) { + scale = 1; + } + window.scale = scale; + long now = System.currentTimeMillis(); + Map node; + Map state; + Map images; + int logicalW; + int logicalH; + boolean dark; + int bgColor; + long nextFlip = 0; + if (window.kindId != null) { + Map doc; + synchronized (this) { + doc = timelines.get(window.kindId); + images = imagesOf(window.kindId); + } + int index = sizeIndex(window.sizeName); + logicalW = SIZE_W[index]; + logicalH = SIZE_H[index]; + node = SurfaceRasterizer.layoutForSize(doc, window.sizeName); + if (node == null) { + // nothing published yet: show the kind's display name as a placeholder + node = placeholderNode(window.kindId); + state = new HashMap(); + } else { + Map entry = SurfaceRasterizer.currentEntry(doc, now); + state = entry == null ? new HashMap() + : asMap(entry.get("state")); + nextFlip = SurfaceRasterizer.nextEntryFlip(doc, now); + } + dark = isSystemDark(); + bgColor = dark ? 0x1c1c1e : 0xffffff; + } else { + ActivityRecord rec; + synchronized (this) { + rec = liveActivities.get(window.activityId); + } + if (rec == null) { + // ended: keep whatever pixels are showing during the linger period + return; + } + logicalW = PILL_W; + logicalH = PILL_H; + node = buildPillNode(rec.descriptor); + state = rec.state; + images = rec.images; + // the pill is always dark, matching the hardware cutout it mimics + dark = true; + bgColor = 0x000000; + } + + int pw = Math.max(1, Math.round(logicalW * scale)); + int ph = Math.max(1, Math.round(logicalH * scale)); + SurfaceRasterizer.Result result = SurfaceRasterizer.rasterize( + scaleNodeDips(node, scale), state, images, pw, ph, dark, now); + + // compose over the rounded platform background: transparent corners in + // the final ARGB become per-pixel alpha in the layered window, so the + // widget's rounded shape comes straight from these pixels + Image composed = Image.createImage(pw, ph, 0); + Graphics g = composed.getGraphics(); + g.setColor(bgColor); + int arc = window.kindId != null ? Math.round(CORNER * scale) : ph; + g.fillRoundRect(0, 0, pw, ph, arc, arc); + g.drawImage(result.getImage(), 0, 0); + + WindowsNative.widgetUpdatePixels(handle, composed.getRGB(), pw, ph); + List actions = result.getActions(); + int[] packed = new int[actions.size() * 4]; + for (int i = 0; i < actions.size(); i++) { + SurfaceRasterizer.ActionRect r = actions.get(i); + packed[i * 4] = r.getX(); + packed[i * 4 + 1] = r.getY(); + packed[i * 4 + 2] = r.getWidth(); + packed[i * 4 + 3] = r.getHeight(); + } + WindowsNative.widgetSetHitRects(handle, packed); + synchronized (this) { + window.actions = actions; + } + scheduleRefresh(window, result.getNextTickMillis(), nextFlip, now); + } + + /** Schedules the next time-driven re-render (countdown tick / entry flip). */ + private void scheduleRefresh(final SurfaceWindow window, long nextTick, long nextFlip, + long now) { + long due = nextTick; + if (nextFlip > 0 && (due == 0 || nextFlip < due)) { + due = nextFlip; + } + synchronized (this) { + if (window.refreshTask != null) { + window.refreshTask.cancel(); + window.refreshTask = null; + } + if (due <= 0 || window.closed) { + return; + } + window.refreshTask = new TimerTask() { + @Override + public void run() { + requestRender(window); + } + }; + timer.schedule(window.refreshTask, Math.max(50, due - now)); + } + } + + /** A minimal descriptor showing the kind's display name until content is published. */ + private Map placeholderNode(String kindId) { + String name = kindId; + synchronized (this) { + Map kind = kinds.get(kindId); + if (kind != null && kind.get("name") instanceof String) { + name = (String) kind.get("name"); + } + } + Map text = new LinkedHashMap(); + text.put("t", "text"); + text.put("text", name); + Map box = new LinkedHashMap(); + box.put("t", "box"); + List children = new ArrayList(); + children.add(text); + box.put("ch", children); + return box; + } + + /** + * Synthesizes the compact live-activity pill layout: compact leading and + * trailing regions pushed apart by a spacer, falling back to the minimal + * region or the activity type name (the same shape the JavaSE pill uses). + */ + private static Map buildPillNode(Map descriptor) { + Map island = asMap(descriptor.get("island")); + Object leading = island.get("compactLeading"); + Object trailing = island.get("compactTrailing"); + Map row = new LinkedHashMap(); + row.put("t", "row"); + List pad = new ArrayList(); + pad.add(Integer.valueOf(4)); + pad.add(Integer.valueOf(14)); + pad.add(Integer.valueOf(4)); + pad.add(Integer.valueOf(14)); + row.put("pad", pad); + row.put("spacing", Integer.valueOf(8)); + List children = new ArrayList(); + if (leading instanceof Map || trailing instanceof Map) { + if (leading instanceof Map) { + children.add(leading); + } + Map spacer = new LinkedHashMap(); + spacer.put("t", "spacer"); + children.add(spacer); + if (trailing instanceof Map) { + children.add(trailing); + } + } else if (island.get("minimal") instanceof Map) { + children.add(island.get("minimal")); + } else { + Map label = new LinkedHashMap(); + label.put("t", "text"); + Object type = descriptor.get("type"); + label.put("text", type instanceof String ? type : "live activity"); + label.put("size", Integer.valueOf(12)); + children.add(label); + } + row.put("ch", children); + return row; + } + + /** + * Deep-copies a descriptor node multiplying every dip-valued attribute by + * {@code scale}, so the rasterizer (which treats dips as pixels) produces a + * crisp DPI-scaled rendering; the action rectangles it returns are then in + * scaled pixels as well. A local float-scale replica of the JavaSE bridge's + * helper (that class is not on the Windows port classpath). + */ + @SuppressWarnings("unchecked") + private static Map scaleNodeDips(Map node, float scale) { + if (node == null || scale == 1) { + return node; + } + Map out = new LinkedHashMap(); + for (Map.Entry e : node.entrySet()) { + String key = e.getKey(); + Object value = e.getValue(); + if ("ch".equals(key) && value instanceof List) { + List children = new ArrayList(); + for (Object child : (List) value) { + if (child instanceof Map) { + children.add(scaleNodeDips((Map) child, scale)); + } else { + children.add(child); + } + } + out.put(key, children); + } else if ("pad".equals(key) && value instanceof List) { + List pad = new ArrayList(); + for (Object p : (List) value) { + pad.add(p instanceof Number + ? (Object) Integer.valueOf(Math.round(((Number) p).intValue() * scale)) + : p); + } + out.put(key, pad); + } else if (isDipKey(key) && value instanceof Number) { + out.put(key, Integer.valueOf(Math.round(((Number) value).intValue() * scale))); + } else { + out.put(key, value); + } + } + return out; + } + + private static boolean isDipKey(String key) { + return "w".equals(key) || "h".equals(key) || "corner".equals(key) + || "spacing".equals(key) || "min".equals(key) || "size".equals(key); + } + + // --- persistence ------------------------------------------------------------- + + private String kindDir(String kindId) { + String dir = root + "\\" + sanitize(kindId); + WindowsNative.fileMkdir(dir); + return dir; + } + + private static String sanitize(String kindId) { + StringBuilder sb = new StringBuilder(kindId.length()); + for (int i = 0; i < kindId.length(); i++) { + char c = kindId.charAt(i); + if (Character.isLetterOrDigit(c) || c == '_' || c == '-') { + sb.append(c); + } else { + sb.append('_'); + } + } + return sb.toString(); + } + + /** + * Atomic write-rename (MoveFileEx REPLACE_EXISTING under the native) so a + * concurrent reader -- including the MSIX Widgets Board provider process -- + * never sees a torn file. + */ + private static void writeFileSafely(String path, byte[] data) { + if (data == null) { + return; + } + String tmp = path + ".tmp"; + long handle = WindowsNative.fileOpenWrite(tmp, false); + if (handle == 0) { + return; + } + WindowsNative.fileWrite(handle, data, 0, data.length); + WindowsNative.fileClose(handle); + WindowsNative.fileRename(tmp, path); + } + + private static byte[] readFile(String path) { + if (!WindowsNative.fileExists(path)) { + return null; + } + long handle = WindowsNative.fileOpenRead(path); + if (handle == 0) { + return null; + } + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int len = WindowsNative.fileRead(handle, buf, 0, buf.length); + while (len > 0) { + bo.write(buf, 0, len); + len = WindowsNative.fileRead(handle, buf, 0, buf.length); + } + WindowsNative.fileClose(handle); + return bo.toByteArray(); + } + + private void restoreFromDisk() { + String[] dirs = WindowsNative.fileList(root); + if (dirs == null) { + return; + } + for (String name : dirs) { + if (name == null || ".".equals(name) || "..".equals(name)) { + continue; + } + String dir = root + "\\" + name; + if (!WindowsNative.fileIsDirectory(dir)) { + continue; + } + String kindId = name; + byte[] kindData = readFile(dir + "\\kind.json"); + if (kindData != null) { + Map kind = parse(string(kindData)); + if (kind != null && kind.get("id") instanceof String) { + kindId = (String) kind.get("id"); + kinds.put(kindId, kind); + } + } + byte[] timelineData = readFile(dir + "\\timeline.json"); + if (timelineData != null) { + Map doc = parse(string(timelineData)); + if (doc != null) { + timelines.put(kindId, doc); + kindImages.put(kindId, readImages(dir)); + } + } + } + } + + private static Map readImages(String dir) { + Map images = new HashMap(); + String[] files = WindowsNative.fileList(dir); + if (files == null) { + return images; + } + for (String name : files) { + if (name != null && name.endsWith(".png")) { + byte[] data = readFile(dir + "\\" + name); + if (data != null) { + images.put(name.substring(0, name.length() - 4), data); + } + } + } + return images; + } + + /** + * Delivers actions the MSIX Widgets Board provider queued while the app + * was not running: the out-of-process provider appends + * {@code sourceactionIdparamsJson} lines to + * {@code cn1surfaces\pending_actions.txt} and launches the app; this drains + * the file into {@code Surfaces.dispatchAction} (whose cold-start queue + * holds them until the app registers a handler) and deletes it. + */ + private void drainBoardActions() { + String path = root + "\\pending_actions.txt"; + byte[] data = readFile(path); + if (data == null) { + return; + } + WindowsNative.fileDelete(path); + for (String rawLine : split(string(data), '\n')) { + String line = rawLine.trim(); + if (line.length() == 0) { + continue; + } + String[] fields = split(line, '\t'); + if (fields.length < 2) { + continue; + } + Map params = fields.length > 2 ? parse(fields[2]) : null; + Surfaces.dispatchAction(fields[0], fields[1], params); + } + } + + // --- small helpers ------------------------------------------------------------- + + private synchronized Map imagesOf(String kindId) { + Map images = kindImages.get(kindId); + return images == null ? new HashMap() : images; + } + + private static String normalizeSize(String sizeName) { + for (String s : SIZE_NAMES) { + if (s.equals(sizeName)) { + return s; + } + } + return "small"; + } + + private static int sizeIndex(String sizeName) { + for (int i = 0; i < SIZE_NAMES.length; i++) { + if (SIZE_NAMES[i].equals(sizeName)) { + return i; + } + } + return 0; + } + + /** Follows the display's dark-mode flag when the port reports one; defaults to light. */ + private static boolean isSystemDark() { + // Assign-in-try / decide-outside-try: returning a freshly merged boolean + // from inside the translated try block ICEs gcc (SSA corruption) on the + // ParparVM-generated C -- see the identical note in LinuxWidgetBridge. + Boolean dark = null; + try { + dark = Display.getInstance().isDarkMode(); + } catch (Throwable t) { + // headless or uninitialized display: default to light + } + return dark != null && dark.booleanValue(); + } + + /** String.split without regex (the translated runtime keeps regex minimal). */ + private static String[] split(String s, char sep) { + List parts = new ArrayList(); + int start = 0; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == sep) { + parts.add(s.substring(start, i)); + start = i + 1; + } + } + parts.add(s.substring(start)); + return parts.toArray(new String[parts.size()]); + } + + private static byte[] utf8(String s) { + try { + return s.getBytes("UTF-8"); + } catch (IOException err) { + return s.getBytes(); + } + } + + private static String string(byte[] data) { + try { + return new String(data, "UTF-8"); + } catch (IOException err) { + return new String(data); + } + } + + @SuppressWarnings("unchecked") + private static Map parse(String json) { + if (json == null) { + return null; + } + try { + return new JSONParser().parseJSON(new StringReader(json)); + } catch (Exception err) { + err.printStackTrace(); + return null; + } + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object o) { + return o instanceof Map ? (Map) o : new HashMap(); + } +} diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index 6ae2af4420a..23d2ac4f0c9 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -37,6 +37,11 @@ #include "com_codename1_impl_ios_IOSNative.h" #include "com_codename1_push_PushContent.h" #include "com_codename1_ui_Display.h" +#ifdef CN1_USE_WIDGETS +// CodenameOne_GLViewController.h (imported above) carries the CN1_USE_WIDGETS define flipped +// by the builder for apps that reference com.codename1.surfaces. +#include "com_codename1_impl_ios_IOSSurfaceCallbacks.h" +#endif #ifdef NEW_CODENAME_ONE_VM #include "java_lang_System.h" int mallocWhileSuspended = 0; @@ -348,9 +353,41 @@ - (BOOL)cn1OpenURL:(UIApplication *)application url:(NSURL *)url sourceApplicati return fbRes; } #endif - + +#ifdef CN1_USE_WIDGETS + // Surface action deep link (cn1surface://a?src=..&id=..&p=) from a widget + // or live activity tap. Decode and hand it straight to the Java framework; + // Surfaces.dispatchAction queues internally until the app registers its action handler, so + // cold-start taps are safe (every openURL path -- delegate, legacy handleOpenURL and the + // scene delegate's connection/openURLContexts callbacks -- funnels through cn1OpenURL after + // the VM is up, exactly like the shouldApplicationHandleURL call below). These URLs are + // consumed here: do NOT store them in AppArg and report them handled so no other machinery + // sees them. + if (url.scheme != nil && [@"cn1surface" caseInsensitiveCompare:url.scheme] == NSOrderedSame) { + NSURLComponents *cn1SurfaceComponents = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; + NSString *cn1SurfaceSrc = nil; + NSString *cn1SurfaceActionId = nil; + NSString *cn1SurfaceParams = nil; + for (NSURLQueryItem *item in cn1SurfaceComponents.queryItems) { + if ([item.name isEqualToString:@"src"]) { + cn1SurfaceSrc = item.value; + } else if ([item.name isEqualToString:@"id"]) { + cn1SurfaceActionId = item.value; + } else if ([item.name isEqualToString:@"p"]) { + // NSURLQueryItem.value is already percent-decoded JSON. + cn1SurfaceParams = item.value; + } + } + JAVA_OBJECT jSurfaceSrc = cn1SurfaceSrc == nil ? JAVA_NULL : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG cn1SurfaceSrc); + JAVA_OBJECT jSurfaceActionId = cn1SurfaceActionId == nil ? JAVA_NULL : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG cn1SurfaceActionId); + JAVA_OBJECT jSurfaceParams = cn1SurfaceParams == nil ? JAVA_NULL : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG cn1SurfaceParams); + com_codename1_impl_ios_IOSSurfaceCallbacks_nativeSurfaceAction___java_lang_String_java_lang_String_java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG jSurfaceSrc, jSurfaceActionId, jSurfaceParams); + return YES; + } +#endif // CN1_USE_WIDGETS + //openURLMarkerEntry - + #ifdef NEW_CODENAME_ONE_VM JAVA_BOOLEAN b = com_codename1_impl_ios_IOSImplementation_shouldApplicationHandleURL___java_lang_String_java_lang_String_R_boolean(CN1_THREAD_GET_STATE_PASS_ARG str1, str2); #else diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index 93a2dcb66d0..e5bd9a3881b 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -136,6 +136,19 @@ void cn1RunSyncOnMainQueue(void (^block)(void)); #undef CN1_USE_CARPLAY #endif +// CN1_USE_WIDGETS gates the external surfaces native bridge (the IOSNative surfaces* +// trampolines into the Swift CN1SurfaceBridge class plus the cn1surface:// deep link handling +// in CodenameOne_GLAppDelegate). IPhoneBuilder uncomments this only when the classpath scanner +// saw com.codename1.surfaces.*, so apps that never publish widgets or live activities ship +// without any WidgetKit/ActivityKit references and need no app group. Lives in this central +// header (included first by every surfaces TU) so the define is visible across translation +// units, mirroring CN1_USE_CARPLAY. +//#define CN1_USE_WIDGETS +// WidgetKit home-screen widgets are unavailable on watchOS / tvOS; undo the define there. +#if TARGET_OS_WATCH || TARGET_OS_TV +#undef CN1_USE_WIDGETS +#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 422bf34b4af..0c62d91b0d9 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -13942,6 +13942,215 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isCarPlayConnected___R_boolean(CN1 return com_codename1_impl_ios_IOSNative_isCarPlayConnected__(CN1_THREAD_STATE_PASS_ARG instanceObject); } +// --- External surfaces (WidgetKit + ActivityKit) --------------------------- +// Gated by CN1_USE_WIDGETS: the builder uncomments the define (in +// CodenameOne_GLViewController.h, imported near the top of this file), generates the +// CN1Widgets extension target and injects the CN1SurfacesAppGroup Info.plist key when the +// app references com.codename1.surfaces. Builds without it compile the stub branch. +// WidgetKit/ActivityKit are Swift-only, so the real implementations are thin trampolines +// onto the Swift CN1SurfaceBridge class (compiled into the app target by the builder), +// reached via NSClassFromString + typed objc_msgSend casts. The Swift side owns its own +// threading (WidgetCenter is thread safe; ActivityKit updates run in Tasks), so these +// call it directly on the calling (CN1) thread. +#ifdef CN1_USE_WIDGETS + +static NSString *cn1SurfacesGroupId() { + id v = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CN1SurfacesAppGroup"]; + return ([v isKindOfClass:[NSString class]] && [(NSString *)v length] > 0) ? (NSString *)v : nil; +} + +static NSString *cn1SurfacesContainerPath() { + NSString *group = cn1SurfacesGroupId(); + if (group == nil) { + return nil; + } + NSURL *container = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:group]; + return container == nil ? nil : container.path; +} + +static Class cn1SurfacesBridgeClass() { + return NSClassFromString(@"CN1SurfaceBridge"); +} + +// True when the running OS meets the CN1Widgets extension's deployment target +// (CN1SurfacesMinOS Info.plist key, injected by the builder from +// ios.surfaces.deploymentTarget; defaults to 16.1). Below that version the +// extension cannot run or appear in the widget gallery, so the API must not +// report widget support even though WidgetKit itself shipped with iOS 14. +static BOOL cn1SurfacesMinOSSupported() { + NSString *min = nil; + id v = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CN1SurfacesMinOS"]; + if ([v isKindOfClass:[NSString class]] && [(NSString *)v length] > 0) { + min = (NSString *)v; + } else { + min = @"16.1"; + } + NSArray *parts = [min componentsSeparatedByString:@"."]; + NSOperatingSystemVersion required; + required.majorVersion = parts.count > 0 ? [[parts objectAtIndex:0] integerValue] : 16; + required.minorVersion = parts.count > 1 ? [[parts objectAtIndex:1] integerValue] : 1; + required.patchVersion = parts.count > 2 ? [[parts objectAtIndex:2] integerValue] : 0; + return [[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:required]; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_getSurfacesContainerPath__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + POOL_BEGIN(); + NSString *path = cn1SurfacesContainerPath(); + JAVA_OBJECT result = fromNSString(CN1_THREAD_STATE_PASS_ARG (path == nil ? @"" : path)); + POOL_END(); + return result; +} + +void com_codename1_impl_ios_IOSNative_surfacesReloadTimelines___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT kind) { + if (@available(iOS 14.0, *)) { + POOL_BEGIN(); + Class bridge = cn1SurfacesBridgeClass(); + if (bridge != nil) { + NSString *k = (kind == JAVA_NULL) ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG kind); + ((void (*)(id, SEL, NSString *))objc_msgSend)((id)bridge, + NSSelectorFromString(@"reloadTimelines:"), k); + } + POOL_END(); + } +} + +JAVA_INT com_codename1_impl_ios_IOSNative_surfacesInstalledCount___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT kind) { + if (@available(iOS 14.0, *)) { + POOL_BEGIN(); + JAVA_INT count = 0; + Class bridge = cn1SurfacesBridgeClass(); + if (bridge != nil) { + NSString *k = (kind == JAVA_NULL) ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG kind); + count = (JAVA_INT)((NSInteger (*)(id, SEL, NSString *))objc_msgSend)((id)bridge, + NSSelectorFromString(@"installedCount:"), k); + } + POOL_END(); + return count; + } + return 0; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_surfacesStartActivity___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT descriptorJson) { + if (@available(iOS 16.1, *)) { + POOL_BEGIN(); + JAVA_OBJECT result = JAVA_NULL; + Class bridge = cn1SurfacesBridgeClass(); + if (bridge != nil && descriptorJson != JAVA_NULL) { + NSString *json = toNSString(CN1_THREAD_STATE_PASS_ARG descriptorJson); + NSString *activityId = ((NSString *(*)(id, SEL, NSString *))objc_msgSend)((id)bridge, + NSSelectorFromString(@"startActivity:"), json); + if (activityId != nil && [activityId length] > 0) { + result = fromNSString(CN1_THREAD_STATE_PASS_ARG activityId); + } + } + POOL_END(); + return result; + } + return JAVA_NULL; +} + +void com_codename1_impl_ios_IOSNative_surfacesUpdateActivity___java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT activityId, JAVA_OBJECT stateJson) { + if (@available(iOS 16.1, *)) { + POOL_BEGIN(); + Class bridge = cn1SurfacesBridgeClass(); + if (bridge != nil && activityId != JAVA_NULL) { + NSString *aid = toNSString(CN1_THREAD_STATE_PASS_ARG activityId); + NSString *state = (stateJson == JAVA_NULL) ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG stateJson); + ((void (*)(id, SEL, NSString *, NSString *))objc_msgSend)((id)bridge, + NSSelectorFromString(@"updateActivity:stateJson:"), aid, state); + } + POOL_END(); + } +} + +void com_codename1_impl_ios_IOSNative_surfacesEndActivity___java_lang_String_java_lang_String_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT activityId, JAVA_OBJECT finalStateJson, JAVA_BOOLEAN dismissImmediately) { + if (@available(iOS 16.1, *)) { + POOL_BEGIN(); + Class bridge = cn1SurfacesBridgeClass(); + if (bridge != nil && activityId != JAVA_NULL) { + NSString *aid = toNSString(CN1_THREAD_STATE_PASS_ARG activityId); + NSString *state = (finalStateJson == JAVA_NULL) ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG finalStateJson); + ((void (*)(id, SEL, NSString *, NSString *, BOOL))objc_msgSend)((id)bridge, + NSSelectorFromString(@"endActivity:finalStateJson:immediate:"), + aid, state, dismissImmediately == JAVA_TRUE); + } + POOL_END(); + } +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesWidgetsSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + if (@available(iOS 14.0, *)) { + POOL_BEGIN(); + BOOL supported = cn1SurfacesMinOSSupported() + && cn1SurfacesBridgeClass() != nil && cn1SurfacesContainerPath() != nil; + POOL_END(); + return supported ? JAVA_TRUE : JAVA_FALSE; + } + return JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesActivitiesSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + if (@available(iOS 16.1, *)) { + POOL_BEGIN(); + BOOL supported = NO; + Class bridge = cn1SurfacesBridgeClass(); + if (bridge != nil) { + // The Swift side checks ActivityAuthorizationInfo().areActivitiesEnabled. + supported = ((BOOL (*)(id, SEL))objc_msgSend)((id)bridge, + NSSelectorFromString(@"activitiesEnabled")); + } + POOL_END(); + return supported ? JAVA_TRUE : JAVA_FALSE; + } + return JAVA_FALSE; +} + +#else // CN1_USE_WIDGETS + +// Surfaces not enabled: no WidgetKit/ActivityKit references, everything answers unsupported. +JAVA_OBJECT com_codename1_impl_ios_IOSNative_getSurfacesContainerPath__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_NULL; +} +void com_codename1_impl_ios_IOSNative_surfacesReloadTimelines___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT kind) { +} +JAVA_INT com_codename1_impl_ios_IOSNative_surfacesInstalledCount___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT kind) { + return 0; +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_surfacesStartActivity___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT descriptorJson) { + return JAVA_NULL; +} +void com_codename1_impl_ios_IOSNative_surfacesUpdateActivity___java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT activityId, JAVA_OBJECT stateJson) { +} +void com_codename1_impl_ios_IOSNative_surfacesEndActivity___java_lang_String_java_lang_String_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT activityId, JAVA_OBJECT finalStateJson, JAVA_BOOLEAN dismissImmediately) { +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesWidgetsSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesActivitiesSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} +#endif // CN1_USE_WIDGETS + +// New-VM (return-type-encoded) manglings for the value-returning surfaces natives. Defined +// here, after the implementations/stubs above, so each call is to an already-declared +// function. The void surfaces* methods need no _R_ wrapper. Always defined (real or stub) +// regardless of CN1_USE_WIDGETS. +JAVA_OBJECT com_codename1_impl_ios_IOSNative_getSurfacesContainerPath___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_getSurfacesContainerPath__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_INT com_codename1_impl_ios_IOSNative_surfacesInstalledCount___java_lang_String_R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT kind) { + return com_codename1_impl_ios_IOSNative_surfacesInstalledCount___java_lang_String(CN1_THREAD_STATE_PASS_ARG instanceObject, kind); +} +JAVA_OBJECT com_codename1_impl_ios_IOSNative_surfacesStartActivity___java_lang_String_R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT descriptorJson) { + return com_codename1_impl_ios_IOSNative_surfacesStartActivity___java_lang_String(CN1_THREAD_STATE_PASS_ARG instanceObject, descriptorJson); +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesWidgetsSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_surfacesWidgetsSupported__(CN1_THREAD_STATE_PASS_ARG instanceObject); +} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_surfacesActivitiesSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return com_codename1_impl_ios_IOSNative_surfacesActivitiesSupported__(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 da7451cbee4..384a2aa4cf3 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -367,6 +367,20 @@ public boolean isCarConnected() { return nativeInstance.isCarPlayConnected(); } + private IOSSurfaceBridge surfaceBridge; + + @Override + public com.codename1.surfaces.spi.SurfaceBridge getSurfaceBridge() { + // Only meaningful in builds that linked the surfaces natives (CN1_USE_WIDGETS, flipped by + // the builder when the app references com.codename1.surfaces). Always returned: the + // bridge's own is...Supported methods answer honestly through the natives, which stub to + // unsupported when the define is off. + if (surfaceBridge == null) { + surfaceBridge = IOSSurfaceCallbacks.getBridge(nativeInstance); + } + return surfaceBridge; + } + @Override public void addCookie(Cookie c) { if(isUseNativeCookieStore()) { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 58a714251cc..afc9d2dfc28 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -1002,6 +1002,56 @@ native void walletExtensionAddPassEntry(boolean remote, String identifier, Strin /** Shows a transient CarPlay alert/banner with the supplied message for {@code seconds}. */ native void carPlayShowToast(String message, int seconds); + // --- External surfaces (WidgetKit + ActivityKit) ------------------------- + // All gated natively by CN1_USE_WIDGETS (the build flips it on when the app references + // com.codename1.surfaces). When the define is off these compile to harmless stubs so the + // symbols always resolve. WidgetKit/ActivityKit are Swift-only frameworks; the real + // implementations trampoline into the Swift CN1SurfaceBridge class (compiled into the app + // target by the builder) via NSClassFromString. The Java side (IOSSurfaceBridge) persists + // the serialized payloads into the App Group container before calling these. + + /** + * Returns the filesystem path of the App Group container shared with the CN1Widgets + * extension (group id from the CN1SurfacesAppGroup Info.plist key), or an empty string + * when no usable app group exists. + */ + native String getSurfacesContainerPath(); + + /** + * Asks WidgetCenter to re-render the widgets of {@code kind} from their persisted + * timelines; an empty string reloads all kinds. + */ + native void surfacesReloadTimelines(String kind); + + /** + * Number of widget instances of {@code kind} the user placed on their home/lock screen. + * WidgetCenter answers asynchronously so this returns the last cached answer, 0 when + * still unknown. + */ + native int surfacesInstalledCount(String kind); + + /** + * Starts an ActivityKit live activity from the serialized descriptor (which embeds the + * initial state). Returns the platform activity id, or an empty string on failure. + */ + native String surfacesStartActivity(String descriptorJson); + + /** Pushes a fresh state map into a running live activity; the views re-interpolate locally. */ + native void surfacesUpdateActivity(String activityId, String stateJson); + + /** + * Ends a live activity, optionally showing {@code finalStateJson} before dismissal. + * {@code dismissImmediately} removes the surface right away instead of letting iOS + * linger on the final state. + */ + native void surfacesEndActivity(String activityId, String finalStateJson, boolean dismissImmediately); + + /** True when this build/device can render WidgetKit widgets (iOS 14+, app group resolvable). */ + native boolean surfacesWidgetsSupported(); + + /** True when ActivityKit live activities are available and enabled (iOS 16.1+). */ + native boolean surfacesActivitiesSupported(); + // --- 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/IOSSurfaceBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java new file mode 100644 index 00000000000..48278f5dccc --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceBridge.java @@ -0,0 +1,274 @@ +/* + * 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.io.FileSystemStorage; +import com.codename1.io.JSONParser; +import com.codename1.io.Log; +import com.codename1.surfaces.spi.SurfaceBridge; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Map; + +/// iOS `SurfaceBridge` backing the `com.codename1.surfaces` API with WidgetKit and ActivityKit. +/// +/// Published payloads are persisted into the shared App Group container (the group id comes from +/// the `CN1SurfacesAppGroup` Info.plist key injected by the build) where the generated CN1Widgets +/// extension reads them while the app process is dead: +/// +/// - widget timelines: `/cn1surfaces//timeline.json` plus `.png` blobs +/// - live activity art: `/cn1surfaces/activities/.png` +/// +/// timeline.json is written write-then-rename so the extension never observes a partial file. +/// WidgetCenter reloads and the ActivityKit lifecycle go through the `surfaces*` natives in +/// `IOSNative`, which trampoline into the Swift `CN1SurfaceBridge` class compiled into the app +/// target (WidgetKit/ActivityKit are Swift-only frameworks). +/// +/// All file IO goes through `FileSystemStorage` (which tolerates the container's plain absolute +/// paths): `java.io.File`'s mutating methods are unimplemented natives on the ParparVM iOS +/// runtime -- referencing them fails the native link. +/// +/// This whole class is dead code unless the build linked the surfaces natives (the +/// `CN1_USE_WIDGETS` define the builder flips when the app references `com.codename1.surfaces`); +/// without it every native answers unsupported and the public API no-ops. +final class IOSSurfaceBridge implements SurfaceBridge { + private final IOSNative nativeInstance; + private final FileSystemStorage fs = FileSystemStorage.getInstance(); + private boolean warnedNoContainer; + + IOSSurfaceBridge(IOSNative nativeInstance) { + this.nativeInstance = nativeInstance; + } + + public boolean areWidgetsSupported() { + return nativeInstance.surfacesWidgetsSupported(); + } + + public boolean isLiveActivitySupported() { + return nativeInstance.surfacesActivitiesSupported(); + } + + public void registerWidgetKind(String kindJson) { + String container = containerPath(); + if (container == null || kindJson == null) { + return; + } + try { + Map kind = JSONParser.parseJSON(kindJson); + Object id = kind.get("id"); + if (!(id instanceof String) || ((String) id).length() == 0) { + return; + } + String kindsDir = container + "/cn1surfaces/kinds"; + mkdirs(container, "cn1surfaces/kinds"); + writeAtomically(kindsDir, id + ".json", kindJson.getBytes("UTF-8")); + } catch (IOException e) { + Log.e(e); + } + } + + public void publishWidgetTimeline(String kindId, String timelineJson, + Map images) { + String container = containerPath(); + if (container == null) { + return; + } + try { + String kindDir = container + "/cn1surfaces/" + kindId; + mkdirs(container, "cn1surfaces/" + kindId); + writeImages(kindDir, images); + writeAtomically(kindDir, "timeline.json", timelineJson.getBytes("UTF-8")); + // GC after the replacement timeline is in place: content-hash names mean + // frequently changing art would otherwise grow the container without bound + deleteUnreferencedImages(kindDir, timelineJson); + } catch (IOException e) { + Log.e(e); + return; + } + nativeInstance.surfacesReloadTimelines(kindId); + } + + public void reloadWidgets(String kindId) { + nativeInstance.surfacesReloadTimelines(kindId == null ? "" : kindId); + } + + public int getInstalledWidgetCount(String kindId) { + // WidgetCenter.getCurrentConfigurations is async; the Swift bridge caches the last + // answer and the native returns 0 while it is still unknown. + return nativeInstance.surfacesInstalledCount(kindId); + } + + public String startLiveActivity(String descriptorJson, Map images) { + String container = containerPath(); + if (container != null && images != null && !images.isEmpty()) { + // Image names are content hashes so a shared directory dedups across activities; the + // descriptor references them by name exactly like a widget timeline does. The Swift + // bridge sweeps this directory against the still-live/lingering Activity.activities + // set on every start and end, so blobs from ended activities (and from a start that + // then fails) are reclaimed instead of growing without bound. + try { + String actDir = container + "/cn1surfaces/activities"; + mkdirs(container, "cn1surfaces/activities"); + writeImages(actDir, images); + } catch (IOException e) { + Log.e(e); + } + } + String id = nativeInstance.surfacesStartActivity(descriptorJson); + if (id == null || id.length() == 0) { + return null; + } + return id; + } + + public void updateLiveActivity(String activityId, String stateJson) { + nativeInstance.surfacesUpdateActivity(activityId, stateJson); + } + + public void endLiveActivity(String activityId, String finalStateJson, + boolean dismissImmediately) { + nativeInstance.surfacesEndActivity(activityId, finalStateJson, dismissImmediately); + } + + // --- internals ------------------------------------------------------------ + + /// Resolves the App Group container path, or null (with a one-time log) when the build has + /// no usable app group -- e.g. the CN1_USE_WIDGETS define is off or the group is missing from + /// the provisioning profile so containerURLForSecurityApplicationGroupIdentifier fails. + private String containerPath() { + String container = nativeInstance.getSurfacesContainerPath(); + if (container == null || container.length() == 0) { + if (!warnedNoContainer) { + warnedNoContainer = true; + Log.p("Surfaces: no App Group container is available; check that the build " + + "declared surfaces.json and that the app group in the " + + "CN1SurfacesAppGroup Info.plist key exists on the App ID"); + } + return null; + } + if (container.endsWith("/")) { + container = container.substring(0, container.length() - 1); + } + return container; + } + + private void writeImages(String dir, Map images) throws IOException { + if (images == null) { + return; + } + for (Map.Entry e : images.entrySet()) { + String png = dir + "/" + e.getKey() + ".png"; + if (fs.exists(png)) { + // Names derive from a content hash, so an existing file is the same bytes. + continue; + } + write(png, e.getValue()); + } + } + + /// Writes `.tmp` then renames over the target so the widget extension, which may read + /// concurrently from another process, never sees a partially written document. + private void writeAtomically(String dir, String name, byte[] data) throws IOException { + String target = dir + "/" + name; + String tmp = target + ".tmp"; + write(tmp, data); + if (fs.exists(target)) { + fs.delete(target); + } + // a relative new name renames within the same directory + fs.rename(tmp, name); + if (!fs.exists(target)) { + throw new IOException("Failed to rename " + tmp + " to " + target); + } + } + + private void write(String path, byte[] data) throws IOException { + OutputStream os = fs.openOutputStream(path); + try { + os.write(data); + } finally { + os.close(); + } + } + + /// Creates the nested directories of `relative` (slash-separated) under `base` one level at + /// a time: `FileSystemStorage.mkdir` has no mkdirs equivalent. + private void mkdirs(String base, String relative) { + StringBuilder current = new StringBuilder(base); + int start = 0; + while (start < relative.length()) { + int slash = relative.indexOf('/', start); + String segment = slash < 0 ? relative.substring(start) + : relative.substring(start, slash); + if (segment.length() > 0) { + current.append('/').append(segment); + String path = current.toString(); + if (!fs.exists(path)) { + fs.mkdir(path); + } + } + if (slash < 0) { + break; + } + start = slash + 1; + } + } + + /// Deletes `.png` blobs in the kind directory that the freshly published timeline no + /// longer references. The document's `images` list is the complete reference set (the + /// serializer includes registered-name references, not just newly shipped blobs). Runs after + /// the new `timeline.json` is in place so a concurrently rendering extension re-reads the + /// replacement document first. + private void deleteUnreferencedImages(String kindDir, String timelineJson) { + try { + Map doc = new JSONParser() + .parseJSON(new java.io.StringReader(timelineJson)); + Object names = doc.get("images"); + java.util.Set referenced = new java.util.HashSet(); + if (names instanceof java.util.Collection) { + for (Object o : (java.util.Collection) names) { + referenced.add(String.valueOf(o)); + } + } + String[] files = fs.listFiles(kindDir); + if (files == null) { + return; + } + for (String name : files) { + if (name == null) { + continue; + } + // listFiles returns child names; directories carry a trailing slash on some ports + if (name.endsWith("/")) { + continue; + } + if (name.endsWith(".png") + && !referenced.contains(name.substring(0, name.length() - 4))) { + fs.delete(kindDir + "/" + name); + } + } + } catch (Exception e) { + Log.e(e); + } + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceCallbacks.java new file mode 100644 index 00000000000..1bfc9863e56 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSurfaceCallbacks.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.ios; + +import com.codename1.io.JSONParser; +import com.codename1.io.Log; +import com.codename1.surfaces.Surfaces; + +import java.util.Map; + +/// Static callback surface invoked from the native surfaces glue (the `cn1surface://` deep link +/// handling in `CodenameOne_GLAppDelegate`). Mirrors the `IOSCarPlayCallbacks` pattern: the static +/// initializer calls each native callback once (guarded so it has no effect) purely to keep the +/// ParparVM dead-code eliminator from stripping the callback targets, which otherwise have no Java +/// caller. +final class IOSSurfaceCallbacks { + private static IOSSurfaceBridge bridge; + private static boolean dceGuard; + + static { + // Keep the native callback targets reachable for the iOS VM optimizer. + dceGuard = true; + nativeSurfaceAction(null, null, null); + dceGuard = false; + } + + private IOSSurfaceCallbacks() { + } + + /// Returns the singleton surfaces bridge, creating it on first use. + static synchronized IOSSurfaceBridge getBridge(IOSNative nativeInstance) { + if (bridge == null) { + bridge = new IOSSurfaceBridge(nativeInstance); + } + return bridge; + } + + // ---- Callbacks invoked from native code (do not rename) ---------------- + + /// Called from native when the user taps a widget or live activity element carrying an + /// action; the parameters mirror the canonical deep link + /// `cn1surface://a?src=..&id=..&p=`. `Surfaces.dispatchAction` handles EDT + /// marshaling and cold-start queuing internally, so invoking this early in the launch + /// sequence is safe. + public static void nativeSurfaceAction(String source, String actionId, String paramsJson) { + if (dceGuard) { + return; + } + Map params = null; + if (paramsJson != null && paramsJson.length() > 0) { + try { + params = JSONParser.parseJSON(paramsJson); + } catch (Throwable t) { + Log.e(t); + } + } + Surfaces.dispatchAction(source, actionId, params); + } +} diff --git a/Samples/samples/SurfacesSample/SurfacesSample.java b/Samples/samples/SurfacesSample/SurfacesSample.java new file mode 100644 index 00000000000..ec3aeda601c --- /dev/null +++ b/Samples/samples/SurfacesSample/SurfacesSample.java @@ -0,0 +1,410 @@ +/* + * 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.samples; + +import com.codename1.background.BackgroundFetch; +import com.codename1.surfaces.LiveActivity; +import com.codename1.surfaces.LiveActivityDescriptor; +import com.codename1.surfaces.SurfaceActionEvent; +import com.codename1.surfaces.SurfaceColor; +import com.codename1.surfaces.SurfaceColumn; +import com.codename1.surfaces.SurfaceDynamicText; +import com.codename1.surfaces.SurfaceFontWeight; +import com.codename1.surfaces.SurfaceImage; +import com.codename1.surfaces.SurfaceNode; +import com.codename1.surfaces.SurfaceProgress; +import com.codename1.surfaces.SurfaceRow; +import com.codename1.surfaces.SurfaceText; +import com.codename1.surfaces.SurfaceVector; +import com.codename1.surfaces.Surfaces; +import com.codename1.surfaces.WidgetKind; +import com.codename1.surfaces.WidgetSize; +import com.codename1.surfaces.WidgetTimeline; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.Toolbar; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; +import com.codename1.ui.util.UITimer; +import com.codename1.util.Callback; + +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * Demonstrates the external surfaces API ({@code com.codename1.surfaces}): one declarative model + * for home-screen widgets and live activities. The sample is a delivery tracker -- it publishes a + * widget timeline that advances from "preparing" to "delivered" over four minutes with an + * OS-animated countdown to the ETA, and runs a live activity with Dynamic Island regions that the + * app updates as the delivery progresses. A second widget kind is an analog clock built with + * {@link SurfaceVector}: the vector face publishes once and 60 per-minute timeline entries drive + * the hour/minute hand rotation angles through state keys. + * + *

Where to see the surfaces: in the simulator open "Widgets" > "Widgets Preview" (the mock + * Dynamic Island at the bottom shows the live activity). On an Android device add the "Delivery" + * widget from the launcher's widget gallery; on iOS add it from the home-screen widget gallery and + * watch the live activity on the lock screen / Dynamic Island (iPhone 14 Pro or later). Surfaces + * render while the app process is dead; tapping one opens the app and delivers the action id to + * the handler registered in {@link #init}.

+ * + *

The build wires the native plumbing (iOS WidgetKit extension, Android widget receivers) + * automatically because this sample references {@code com.codename1.surfaces}. Widget kinds must + * also be declared at build time in the {@code surfaces.json} project resource -- see the copy in + * this sample's directory and the {@code com.codename1.surfaces} package documentation.

+ * + *

Run with {@code -Dcn1.surfaces.autodemo=true} to auto-publish the timeline, auto-start the + * live activity and auto-open the simulator Widgets preview two seconds after startup (used for + * automated verification).

+ */ +public class SurfacesSample implements BackgroundFetch { + private static final String KIND_DELIVERY = "delivery_status"; + private static final String KIND_CLOCK = "analog_clock"; + private static final int ACCENT_COLOR = 0xff6a1b9a; + + private Form current; + private Form mainForm; + private Resources theme; + private LiveActivity activity; + private long activityEta; + private float activityProgress; + private Label installedLabel; + private Label activityLabel; + + public void init(Object context) { + theme = UIManager.initFirstTheme("/theme"); + Toolbar.setGlobalToolbar(true); + // Declare the widget kind. This mirrors the surfaces.json build-time manifest (the + // platform widget galleries are compiled into the native app) and belongs in init() so + // the kind is known before anything publishes. + Surfaces.registerWidgetKind(new WidgetKind(KIND_DELIVERY) + .setDisplayName("Delivery") + .setDescription("Track your order") + .addSupportedSize(WidgetSize.SMALL) + .addSupportedSize(WidgetSize.MEDIUM)); + Surfaces.registerWidgetKind(new WidgetKind(KIND_CLOCK) + .setDisplayName("Analog Clock") + .setDescription("A vector clock face") + .addSupportedSize(WidgetSize.SMALL)); + // One handler receives every surface tap on the EDT -- including the tap that + // cold-started the app (queued until this registration, flagged isColdStart()). + Surfaces.setActionHandler(evt -> { + if ("open_order".equals(evt.getActionId())) { + showOrderForm(evt); + } else { + Dialog.show("Surface Action", "Action \"" + evt.getActionId() + "\" from " + + evt.getSource() + "\nparams: " + evt.getParams() + + "\ncold start: " + evt.isColdStart(), "OK", null); + } + }); + } + + public void start() { + if (current != null) { + current.show(); + return; + } + Display d = Display.getInstance(); + // Registers this app for periodic background wakeups; performBackgroundFetch below + // re-publishes the widget timeline with fresh data. Widgets themselves render and tick + // without the app, this is only the data-refresh hook. + d.setPreferredBackgroundFetchInterval(300); + + Form f = new Form("Surfaces Sample", BoxLayout.y()); + f.add(new Label("Widgets supported: " + Surfaces.areWidgetsSupported())); + f.add(new Label("Live activities supported: " + LiveActivity.isSupported())); + f.add(new Label("Background fetch supported: " + d.isBackgroundFetchSupported())); + installedLabel = new Label("Installed delivery widgets: " + + Surfaces.getInstalledWidgetCount(KIND_DELIVERY)); + f.add(installedLabel); + activityLabel = new Label("Live activity: not started"); + f.add(activityLabel); + + Button publish = new Button("Publish widget timeline"); + publish.addActionListener(e -> { + publishDeliveryTimeline(); + installedLabel.setText("Installed delivery widgets: " + + Surfaces.getInstalledWidgetCount(KIND_DELIVERY)); + f.revalidate(); + }); + f.add(publish); + + Button publishClock = new Button("Publish clock widget"); + publishClock.addActionListener(e -> publishClockTimeline()); + f.add(publishClock); + + Button startActivity = new Button("Start Live Activity"); + startActivity.addActionListener(e -> startDeliveryActivity()); + f.add(startActivity); + + Button advance = new Button("Advance state"); + advance.addActionListener(e -> advanceActivity()); + f.add(advance); + + Button end = new Button("End activity"); + end.addActionListener(e -> endActivity()); + f.add(end); + + mainForm = f; + f.show(); + + if ("true".equals(System.getProperty("cn1.surfaces.autodemo"))) { + // Demo/CI mode: publish and start everything shortly after startup so the simulator + // Widgets preview window (opened by the same system property) has content to render + // without any manual clicks. + UITimer.timer(2000, false, f, () -> { + publishDeliveryTimeline(); + publishClockTimeline(); + startDeliveryActivity(); + }); + } + } + + public void stop() { + current = CN.getCurrentForm(); + if (current instanceof Dialog) { + ((Dialog) current).dispose(); + current = CN.getCurrentForm(); + } + } + + public void destroy() { + } + + /** + * Called periodically by the platform while the app is in the background (simulate with + * "Pause App" in the simulator). Re-publishes the delivery timeline with fresh dates, then + * signals completion -- the callback MUST be invoked or iOS stops granting fetch time. + */ + @Override + public void performBackgroundFetch(long deadline, Callback onComplete) { + publishDeliveryTimeline(); + publishClockTimeline(); + onComplete.onSucess(Boolean.TRUE); + } + + /** + * Publishes a four-entry timeline covering the next four minutes: the OS flips entries on + * schedule and interpolates each entry's state map into the layout's ${key} placeholders, + * all without waking the app. The countdown ticks natively between refreshes. + */ + private void publishDeliveryTimeline() { + long now = System.currentTimeMillis(); + long eta = now + 4 * 60000L; + WidgetTimeline timeline = new WidgetTimeline() + .setContent(buildDeliveryLayout()) + .addEntry(new Date(now), deliveryState("Preparing your order", eta, 0.1f)) + .addEntry(new Date(now + 60000L), deliveryState("Out for delivery", eta, 0.4f)) + .addEntry(new Date(now + 3 * 60000L), deliveryState("Arriving now", eta, 0.9f)) + .addEntry(new Date(eta), deliveryState("Delivered", eta, 1f)) + .setReloadPolicy(WidgetTimeline.RELOAD_AT_END); + Surfaces.publish(KIND_DELIVERY, timeline); + } + + /** + * Publishes the analog clock widget: a {@link SurfaceVector} face published once, plus 60 + * per-minute timeline entries carrying only the {@code hourAngle}/{@code minuteAngle} state + * the rotation groups read. The OS flips entries on its own schedule, so the clock stays + * correct for an hour with zero app wakeups; the background fetch hook re-publishes the next + * hour of entries. + */ + private void publishClockTimeline() { + WidgetTimeline timeline = new WidgetTimeline() + .setContent(buildClockFace()) + .setReloadPolicy(WidgetTimeline.RELOAD_AT_END); + Calendar c = Calendar.getInstance(); + c.set(Calendar.SECOND, 0); + c.set(Calendar.MILLISECOND, 0); + long minuteStart = c.getTimeInMillis(); + int baseMinutes = c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE); + for (int m = 0; m < 60; m++) { + int totalMinutes = baseMinutes + m; + Map state = new HashMap<>(); + // clock convention: degrees, 0 = 12 o'clock, clockwise positive + state.put("minuteAngle", totalMinutes % 60 * 6f); + state.put("hourAngle", totalMinutes % 720 * 0.5f); + timeline.addEntry(new Date(minuteStart + m * 60000L), state); + } + Surfaces.publish(KIND_CLOCK, timeline); + } + + /** + * The clock face: a filled dial with twelve tick marks, hour/minute hands driven by + * state-keyed rotation groups and a center hub. Everything is resolution independent -- + * the 200x200 view box scales to whatever size the widget gets. + */ + private SurfaceNode buildClockFace() { + SurfaceVector face = new SurfaceVector(200, 200) + .fillEllipse(100, 100, 96, 96, SurfaceColor.rgb(0xfffafafa, 0xff1c1c1e)) + .strokeEllipse(100, 100, 96, 96, 4, SurfaceColor.rgb(ACCENT_COLOR)); + for (int hour = 0; hour < 12; hour++) { + boolean quarter = hour % 3 == 0; + face.beginRotation(hour * 30f, 100, 100) + .line(100, 10, 100, quarter ? 24 : 18, quarter ? 5 : 3, SurfaceColor.LABEL) + .endRotation(); + } + face.beginRotation("hourAngle", 100, 100) + .line(100, 100, 100, 52, 8, SurfaceColor.LABEL) + .endRotation() + .beginRotation("minuteAngle", 100, 100) + .line(100, 100, 100, 26, 5, SurfaceColor.rgb(ACCENT_COLOR)) + .endRotation() + .fillEllipse(100, 100, 6, 6, SurfaceColor.rgb(ACCENT_COLOR)); + return face; + } + + /** + * The shared delivery layout: courier avatar, status line, OS-animated countdown to the ETA + * and a progress bar. Text and values come from ${key} placeholders / state keys so updates + * only ship a small state map. Tapping the surface delivers the "open_order" action. + */ + private SurfaceNode buildDeliveryLayout() { + Map params = new HashMap<>(); + params.put("orderId", "CN1-12345"); + return new SurfaceColumn().setSpacing(6).setPadding(12) + .add(new SurfaceRow().setSpacing(10) + .add(new SurfaceImage(createCourierAvatar()) + .setSize(40, 40).setCornerRadius(20)) + .add(new SurfaceColumn().setSpacing(2).setWeight(1) + .add(new SurfaceText("${status}") + .setFontSize(15) + .setFontWeight(SurfaceFontWeight.SEMIBOLD) + .setMaxLines(1)) + .add(new SurfaceDynamicText( + SurfaceDynamicText.STYLE_TIMER_DOWN, "eta") + .setFontSize(24) + .setFontWeight(SurfaceFontWeight.BOLD) + .setColor(SurfaceColor.ACCENT)))) + .add(new SurfaceProgress(SurfaceProgress.STYLE_LINEAR) + .setValueState("progress") + .setColor(SurfaceColor.rgb(ACCENT_COLOR))) + .setAction("open_order", params); + } + + /** + * Starts the live activity for the running delivery. The descriptor carries the lock-screen + * content plus the Dynamic Island regions; the returned handle is inert (isActive() false) + * on platforms without live activity support, so no platform checks are needed here. + */ + private void startDeliveryActivity() { + if (activity != null && activity.isActive()) { + Dialog.show("Live Activity", "The delivery activity is already running", "OK", null); + return; + } + activityEta = System.currentTimeMillis() + 4 * 60000L; + activityProgress = 0.25f; + LiveActivityDescriptor descriptor = new LiveActivityDescriptor("delivery") + .setContent(buildDeliveryLayout()) + .setCompactLeading(new SurfaceImage(createCourierAvatar()) + .setSize(24, 24).setCornerRadius(12)) + .setCompactTrailing(new SurfaceDynamicText( + SurfaceDynamicText.STYLE_TIMER_DOWN, "eta") + .setFontSize(14).setColor(SurfaceColor.ACCENT)) + .setExpandedCenter(new SurfaceText("${status}") + .setFontSize(16).setFontWeight(SurfaceFontWeight.SEMIBOLD)) + .setExpandedBottom(new SurfaceProgress(SurfaceProgress.STYLE_LINEAR) + .setValueState("progress") + .setColor(SurfaceColor.rgb(ACCENT_COLOR))) + .setTint(SurfaceColor.rgb(ACCENT_COLOR)); + activity = LiveActivity.start(descriptor, + deliveryState("Out for delivery", activityEta, activityProgress)); + updateActivityLabel(activity.isActive() + ? "Live activity: running (progress 25%)" + : "Live activity: not supported on this platform"); + } + + /** Pushes fresh state to the running activity: +25% progress and a status change. */ + private void advanceActivity() { + if (activity == null || !activity.isActive()) { + Dialog.show("Live Activity", "Start the live activity first", "OK", null); + return; + } + activityProgress = Math.min(1f, activityProgress + 0.25f); + String status = activityProgress >= 1f ? "Arriving now" : "Out for delivery"; + activity.update(deliveryState(status, activityEta, activityProgress)); + updateActivityLabel("Live activity: running (progress " + + (int) (activityProgress * 100) + "%)"); + } + + /** Ends the activity with a final "Delivered" state the platform lingers on briefly. */ + private void endActivity() { + if (activity == null || !activity.isActive()) { + Dialog.show("Live Activity", "No live activity is running", "OK", null); + return; + } + activity.end(deliveryState("Delivered", System.currentTimeMillis(), 1f)); + updateActivityLabel("Live activity: ended"); + } + + private void updateActivityLabel(String text) { + activityLabel.setText(text); + if (CN.getCurrentForm() != null) { + CN.getCurrentForm().revalidate(); + } + } + + /** The "order" form an "open_order" surface tap navigates to, showing the action payload. */ + private void showOrderForm(SurfaceActionEvent evt) { + Form order = new Form("Order", BoxLayout.y()); + order.add(new Label("Order: " + evt.getParams().get("orderId"))); + order.add(new Label("Opened from surface: " + evt.getSource())); + order.add(new Label("Cold start: " + evt.isColdStart())); + Button back = new Button("Back"); + back.addActionListener(e -> { + if (mainForm != null) { + mainForm.show(); + } + }); + order.add(back); + order.show(); + } + + /** + * A small generated mutable image used as the courier avatar. Generated images exercise the + * serializer's PNG encoding path; a real app would typically ship a bundled EncodedImage. + */ + private Image createCourierAvatar() { + Image avatar = Image.createImage(40, 40, ACCENT_COLOR); + Graphics g = avatar.getGraphics(); + g.setColor(0xffffff); + g.fillArc(10, 6, 20, 20, 0, 360); + g.fillArc(6, 28, 28, 18, 0, 360); + return avatar; + } + + private Map deliveryState(String status, long eta, float progress) { + Map state = new HashMap<>(); + state.put("status", status); + state.put("eta", eta); + state.put("progress", progress); + return state; + } +} diff --git a/Samples/samples/SurfacesSample/codenameone_settings.properties b/Samples/samples/SurfacesSample/codenameone_settings.properties new file mode 100644 index 00000000000..03551e7f0e9 --- /dev/null +++ b/Samples/samples/SurfacesSample/codenameone_settings.properties @@ -0,0 +1,11 @@ +#Surfaces sample build hints +# Periodic widget refresh rides on background fetch; iOS requires the fetch background mode +# for Display.setPreferredBackgroundFetchInterval / performBackgroundFetch to run on device. +codename1.arg.ios.background_modes=fetch +# The iOS widget extension and the app share published timelines through an App Group. +# It defaults to group.; uncomment to override (surfaces.json appGroup wins). +#codename1.arg.ios.surfaces.appGroup=group.com.codename1.samples +# Timeline entry flips use inexact alarms on Android by default (countdowns still tick +# natively every second via Chronometer). Uncomment for exact flips (needs user consent +# on Android 12+). +#codename1.arg.android.surfaces.exactAlarms=true diff --git a/Samples/samples/SurfacesSample/surfaces.json b/Samples/samples/SurfacesSample/surfaces.json new file mode 100644 index 00000000000..99c3ff77c75 --- /dev/null +++ b/Samples/samples/SurfacesSample/surfaces.json @@ -0,0 +1,22 @@ +{ + "_comment": "Build-time widget kinds manifest for the surfaces sample. The sample runner only copies the .java file into the generated project, so when building this sample for a device copy this file into the project resources yourself: src/main/resources/surfaces.json in a Maven app (next to .ios.appext files), or the src directory of a legacy Ant project. The ids must mirror the runtime Surfaces.registerWidgetKind(...) calls.", + "liveActivities": true, + "kinds": [ + { + "id": "delivery_status", + "name": "Delivery", + "description": "Track your order", + "iosFamilies": ["systemSmall", "systemMedium"], + "androidMinWidthDp": 180, + "androidMinHeightDp": 60 + }, + { + "id": "analog_clock", + "name": "Analog Clock", + "description": "A vector clock face", + "iosFamilies": ["systemSmall"], + "androidMinWidthDp": 110, + "androidMinHeightDp": 110 + } + ] +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/surfaces/SurfacesSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/surfaces/SurfacesSnippets.java new file mode 100644 index 00000000000..f5d552a6d23 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/surfaces/SurfacesSnippets.java @@ -0,0 +1,196 @@ +package com.codenameone.developerguide.surfaces; + +import com.codename1.surfaces.LiveActivity; +import com.codename1.surfaces.LiveActivityDescriptor; +import com.codename1.surfaces.SurfaceColor; +import com.codename1.surfaces.SurfaceColumn; +import com.codename1.surfaces.SurfaceDynamicText; +import com.codename1.surfaces.SurfaceFontWeight; +import com.codename1.surfaces.SurfaceImage; +import com.codename1.surfaces.SurfaceNode; +import com.codename1.surfaces.SurfaceProgress; +import com.codename1.surfaces.SurfaceRow; +import com.codename1.surfaces.SurfaceText; +import com.codename1.surfaces.SurfaceVector; +import com.codename1.surfaces.Surfaces; +import com.codename1.surfaces.WidgetKind; +import com.codename1.surfaces.WidgetSize; +import com.codename1.surfaces.WidgetTimeline; +import com.codename1.ui.Dialog; +import com.codename1.ui.Image; +import com.codename1.util.Callback; + +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * Snippets that accompany the External Surfaces guide chapter. Each block + * between the tag markers is included verbatim into the AsciiDoc. + */ +public class SurfacesSnippets { + + private static final int ACCENT_COLOR = 0xff6a1b9a; + + private LiveActivity activity; + private Image courierAvatar; + + public void registerKinds() { + // tag::registerKind[] + Surfaces.registerWidgetKind(new WidgetKind("delivery_status") + .setDisplayName("Delivery") + .setDescription("Track your order") + .addSupportedSize(WidgetSize.SMALL) + .addSupportedSize(WidgetSize.MEDIUM)); + // end::registerKind[] + } + + public void registerActionHandler() { + // tag::actionHandler[] + Surfaces.setActionHandler(evt -> { + if ("open_order".equals(evt.getActionId())) { + String orderId = (String) evt.getParams().get("orderId"); + showOrderForm(orderId); + } else { + Dialog.show("Surface Action", "Action " + evt.getActionId() + + " from " + evt.getSource() + + ", cold start: " + evt.isColdStart(), "OK", null); + } + }); + // end::actionHandler[] + } + + public void publishDeliveryTimeline() { + // tag::publishTimeline[] + long now = System.currentTimeMillis(); + long eta = now + 4 * 60000L; + WidgetTimeline timeline = new WidgetTimeline() + .setContent(buildDeliveryLayout()) + .addEntry(new Date(now), deliveryState("Preparing your order", eta, 0.1f)) + .addEntry(new Date(now + 60000L), deliveryState("Out for delivery", eta, 0.4f)) + .addEntry(new Date(now + 3 * 60000L), deliveryState("Arriving now", eta, 0.9f)) + .addEntry(new Date(eta), deliveryState("Delivered", eta, 1f)) + .setReloadPolicy(WidgetTimeline.RELOAD_AT_END); + Surfaces.publish("delivery_status", timeline); + // end::publishTimeline[] + } + + // tag::deliveryLayout[] + private SurfaceNode buildDeliveryLayout() { + Map params = new HashMap<>(); + params.put("orderId", "CN1-12345"); + return new SurfaceColumn().setSpacing(6).setPadding(12) + .add(new SurfaceRow().setSpacing(10) + .add(new SurfaceImage(courierAvatar) + .setSize(40, 40).setCornerRadius(20)) + .add(new SurfaceColumn().setSpacing(2).setWeight(1) + .add(new SurfaceText("${status}") + .setFontSize(15) + .setFontWeight(SurfaceFontWeight.SEMIBOLD) + .setMaxLines(1)) + .add(new SurfaceDynamicText( + SurfaceDynamicText.STYLE_TIMER_DOWN, "eta") + .setFontSize(24) + .setFontWeight(SurfaceFontWeight.BOLD) + .setColor(SurfaceColor.ACCENT)))) + .add(new SurfaceProgress(SurfaceProgress.STYLE_LINEAR) + .setValueState("progress") + .setColor(SurfaceColor.rgb(ACCENT_COLOR))) + .setAction("open_order", params); + } + + private Map deliveryState(String status, long eta, float progress) { + Map state = new HashMap<>(); + state.put("status", status); + state.put("eta", eta); + state.put("progress", progress); + return state; + } + // end::deliveryLayout[] + + // tag::clockFace[] + private SurfaceNode buildClockFace() { + SurfaceVector face = new SurfaceVector(200, 200) + .fillEllipse(100, 100, 96, 96, SurfaceColor.rgb(0xfffafafa, 0xff1c1c1e)) + .strokeEllipse(100, 100, 96, 96, 4, SurfaceColor.rgb(ACCENT_COLOR)); + for (int hour = 0; hour < 12; hour++) { + boolean quarter = hour % 3 == 0; + face.beginRotation(hour * 30f, 100, 100) + .line(100, 10, 100, quarter ? 24 : 18, quarter ? 5 : 3, SurfaceColor.LABEL) + .endRotation(); + } + face.beginRotation("hourAngle", 100, 100) + .line(100, 100, 100, 52, 8, SurfaceColor.LABEL) + .endRotation() + .beginRotation("minuteAngle", 100, 100) + .line(100, 100, 100, 26, 5, SurfaceColor.rgb(ACCENT_COLOR)) + .endRotation() + .fillEllipse(100, 100, 6, 6, SurfaceColor.rgb(ACCENT_COLOR)); + return face; + } + // end::clockFace[] + + public void publishClockTimeline() { + // tag::clockTimeline[] + WidgetTimeline timeline = new WidgetTimeline() + .setContent(buildClockFace()) + .setReloadPolicy(WidgetTimeline.RELOAD_AT_END); + Calendar c = Calendar.getInstance(); + c.set(Calendar.SECOND, 0); + c.set(Calendar.MILLISECOND, 0); + long minuteStart = c.getTimeInMillis(); + int baseMinutes = c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE); + for (int m = 0; m < 60; m++) { + int totalMinutes = baseMinutes + m; + Map state = new HashMap<>(); + // clock convention: degrees, 0 = 12 o'clock, clockwise positive + state.put("minuteAngle", totalMinutes % 60 * 6f); + state.put("hourAngle", totalMinutes % 720 * 0.5f); + timeline.addEntry(new Date(minuteStart + m * 60000L), state); + } + Surfaces.publish("analog_clock", timeline); + // end::clockTimeline[] + } + + public void startLiveActivity() { + // tag::liveActivity[] + long eta = System.currentTimeMillis() + 4 * 60000L; + LiveActivityDescriptor descriptor = new LiveActivityDescriptor("delivery") + .setContent(buildDeliveryLayout()) + .setCompactLeading(new SurfaceImage(courierAvatar) + .setSize(24, 24).setCornerRadius(12)) + .setCompactTrailing(new SurfaceDynamicText( + SurfaceDynamicText.STYLE_TIMER_DOWN, "eta") + .setFontSize(14).setColor(SurfaceColor.ACCENT)) + .setExpandedCenter(new SurfaceText("${status}") + .setFontSize(16).setFontWeight(SurfaceFontWeight.SEMIBOLD)) + .setExpandedBottom(new SurfaceProgress(SurfaceProgress.STYLE_LINEAR) + .setValueState("progress") + .setColor(SurfaceColor.rgb(ACCENT_COLOR))) + .setTint(SurfaceColor.rgb(ACCENT_COLOR)); + activity = LiveActivity.start(descriptor, + deliveryState("Out for delivery", eta, 0.25f)); + // end::liveActivity[] + } + + public void updateAndEndLiveActivity() { + long eta = System.currentTimeMillis() + 2 * 60000L; + // tag::liveActivityUpdate[] + activity.update(deliveryState("Arriving now", eta, 0.9f)); + // ... and when the delivery completes: + activity.end(deliveryState("Delivered", System.currentTimeMillis(), 1f)); + // end::liveActivityUpdate[] + } + + // tag::backgroundFetch[] + public void performBackgroundFetch(long deadline, Callback onComplete) { + // fetch fresh data, then re-publish the timeline + publishDeliveryTimeline(); + onComplete.onSucess(Boolean.TRUE); + } + // end::backgroundFetch[] + + private void showOrderForm(String orderId) { + } +} diff --git a/docs/demos/common/src/main/snippets/developer-guide/external-surfaces.json b/docs/demos/common/src/main/snippets/developer-guide/external-surfaces.json new file mode 100644 index 00000000000..a5b953a076b --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/external-surfaces.json @@ -0,0 +1,25 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::external-surfaces-json-001[] +{ + "liveActivities": true, + "kinds": [ + { + "id": "delivery_status", + "name": "Delivery", + "description": "Track your order", + "iosFamilies": ["systemSmall", "systemMedium"], + "androidMinWidthDp": 180, + "androidMinHeightDp": 60 + }, + { + "id": "analog_clock", + "name": "Analog Clock", + "description": "A vector clock face", + "iosFamilies": ["systemSmall"], + "androidMinWidthDp": 110, + "androidMinHeightDp": 110 + } + ] +} +// end::external-surfaces-json-001[] diff --git a/docs/demos/common/src/main/snippets/developer-guide/external-surfaces.properties b/docs/demos/common/src/main/snippets/developer-guide/external-surfaces.properties new file mode 100644 index 00000000000..bc80d8cea7a --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/external-surfaces.properties @@ -0,0 +1,5 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::external-surfaces-properties-001[] +codename1.arg.ios.background_modes=fetch +// end::external-surfaces-properties-001[] diff --git a/docs/developer-guide/External-Surfaces.asciidoc b/docs/developer-guide/External-Surfaces.asciidoc new file mode 100644 index 00000000000..8b1b470b0a9 --- /dev/null +++ b/docs/developer-guide/External-Surfaces.asciidoc @@ -0,0 +1,203 @@ +== External Surfaces: Widgets and Live Activities + +Home-screen widgets and live activities answer the same developer question: how to keep a live piece of information outside the app. Codename One models both as one concept -- an external surface -- under the `com.codename1.surfaces` package. A *widget* is persistent, user-placed and content-driven: the weather, the next meeting, the delivery status on the home screen. A *live activity* is transient, app-started and progress-driven: the delivery that's out right now, a running timer, a live score on the iOS lock screen and Dynamic Island, an ongoing Android notification, a floating desktop pill. Both share the same layout model, serialization, state mechanism, and action model. + +The simulator renders every surface you publish in a dedicated preview window, so the full loop runs on your desktop with no device: + +image::img/surfaces-widget-preview.png[The simulator Widgets preview rendering the delivery widget with a native countdown and progress bar above the mock Dynamic Island,640] + +=== The dead-process rule + +Surfaces render while your app process may not be running. Everything you hand this API is therefore turned into plain data at publish time: layouts serialize to a compact JSON descriptor, images are encoded to named PNG blobs, and "callbacks" are string action ids that open the app and reach your handler on the EDT. Layout text embeds `${key}` placeholders resolved from a per-entry state map, so a content change is a cheap re-publish of data, not a new layout. + +This rule explains everything unusual about the API. You can't attach a listener to a widget -- the process that renders it may have nothing to call back into. You can't hand it a `Component` -- there is no Codename One renderer on the other side. What you can do is describe the surface declaratively and let each platform's native renderer (SwiftUI on iOS, `RemoteViews` on Android, a rasterizer on desktop) draw it. + +=== Getting started + +The build injects the native plumbing (a WidgetKit extension and App Group on iOS, widget receivers on Android) when your bytecode references `com.codename1.surfaces`. Apps that never touch the package get none of it, and on ports without surface support every entry point is an inert no-op. + +Platform widget galleries are compiled into the native app, so widget kinds must be known at build time. Declare them in a `surfaces.json` resource (in `src/main/resources` of a Maven project, next to your icons): + +[source,json] +---- +include::../demos/common/src/main/snippets/developer-guide/external-surfaces.json[tag=external-surfaces-json-001,indent=0] +---- + +The `id` values must match `[a-z][a-z0-9_]*`. The `iosFamilies` list accepts both the portable names (`small`, `medium`, `large`, `lockscreen`) and the WidgetKit spellings (`systemSmall`, `systemMedium`, `systemLarge`, `accessoryRectangular`); when omitted, all three home-screen sizes are offered. The `androidMinWidthDp` / `androidMinHeightDp` / `androidResizeMode` fields fill the Android provider metadata. An optional top-level `appGroup` pins the iOS App Group id, and `"liveActivities": true` enables the live activity plumbing. + +At runtime, mirror the manifest by registering each kind in your app's `init()`: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/surfaces/SurfacesSnippets.java[tag=registerKind,indent=0] +---- + +A widget renders a *timeline*: a shared layout plus dated entries whose state maps fill the layout's `${key}` placeholders. The OS flips entries on schedule without waking your app. This publishes a delivery tracker that advances through four states: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/surfaces/SurfacesSnippets.java[tag=publishTimeline,indent=0] +---- + +The layout itself is a small tree of nodes with shared styling (padding, background, corner radius, alignment, weight, size, action): + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/surfaces/SurfacesSnippets.java[tag=deliveryLayout,indent=0] +---- + +Three things in this layout carry the model's weight. `SurfaceText` renders `${status}` from the entry state, so updates ship a tiny state map instead of a layout. `SurfaceDynamicText` with `STYLE_TIMER_DOWN` is a countdown the OS animates on its own clock -- the ETA ticks every second with zero app wake-ups. `SurfaceProgress` reads its value from the `progress` state key. The `setAction` call at the root makes a tap anywhere on the widget open the app and deliver the `open_order` action. + +`Surfaces.publish(...)` is callable from any thread and never blocks on the EDT, which matters for background refresh later in this chapter. + +A complete runnable example lives in `Samples/samples/SurfacesSample` together with its `surfaces.json`: + +image::img/surfaces-sample-form.png[The SurfacesSample main form with publish and live activity controls,640] + +==== Previewing in the simulator + +Open *Widgets > Widgets Preview* in the simulator. The window lists your registered kinds, renders the published timeline of the selected kind at any size in light or dark mode, flips timeline entries on schedule, and ticks countdowns exactly as a home-screen widget would. The mock Dynamic Island at the bottom renders running live activities. Clicks map through to your action handler, and a desktop (non-simulator) build renders the same publishes as floating widget windows pinned from a tray icon. + +=== The node catalog + +The catalog is kept small: a set of nodes every platform can render natively. Android app widgets (`RemoteViews`) are the constrained platform, so the catalog is designed to that floor and degrades as follows: + +[options="header"] +|=== +| Node | iOS (SwiftUI) | Android (RemoteViews) | Notes +| `SurfaceColumn` / `SurfaceRow` | `VStack` / `HStack` | `LinearLayout` | Weight maps to `layout_weight` +| `SurfaceBox` | `ZStack` | `FrameLayout` | Child alignment via a 9-way enum +| `SurfaceText` | `Text` | `TextView` | Android maps light/regular/medium weights to regular, semibold/bold to bold +| `SurfaceDynamicText` timer | `Text(date, style:)` | `Chronometer` | Ticks natively on both +| `SurfaceDynamicText` time | `Text(date, style: .time)` | `TextClock` | Native on both +| `SurfaceDynamicText` date/relative | Native | Static text | Android refreshes it on the next update only +| `SurfaceImage` | `Image` | `ImageView` | Named PNG blobs, content-hash dedup +| `SurfaceProgress` linear | `ProgressView` | `ProgressBar` | Value 0..1 or a state key +| `SurfaceProgress` circular | `Gauge` | Falls back to linear | Android widgets lack determinate circular progress +| `SurfaceProgress` date interval | Native animation | Frozen at each refresh | iOS-only nicety +| `SurfaceVector` | SwiftUI `Canvas` | Bitmap rendered in-process | Large vector nodes cost bitmap budget on Android +| `SurfaceSpacer` | `Spacer` | Weighted `View` | Flexible gap +| Corner radius | `clipShape` | Background drawable | May render square below Android 12 +| Node action | `Link` / `widgetURL` | `setOnClickPendingIntent` | Small iOS widgets honor only the root action +|=== + +Descriptors are limited to 8 nesting levels. Keep payloads (JSON plus images) comfortably under 200kb -- the iOS widget extension runs in about 30mb of memory, and Android parcels rendered widgets over a 1mb binder transaction. + +=== Vector widgets + +`SurfaceVector` covers the widgets the template catalog can't express -- clocks, gauges, dials -- with a retained list of vector drawing operations (fills, strokes, lines, arcs, text, rotation groups) replayed natively by every renderer. Rotation groups can read their angle from a state key, which turns a static drawing into a data-driven one. + +The classic example is an analog clock. The face is resolution independent -- the `200x200` view box scales to whatever size the widget gets: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/surfaces/SurfacesSnippets.java[tag=clockFace,indent=0] +---- + +Angles use the clock convention: degrees, 0 at 12 o'clock, clockwise positive. The face publishes once; 60 per-minute timeline entries carry only the two angle values, and the OS flips them on schedule. The clock stays correct for an hour with zero app wake-ups: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/surfaces/SurfacesSnippets.java[tag=clockTimeline,indent=0] +---- + +image::img/surfaces-clock-widget.png[The analog clock widget rendered from a SurfaceVector in the simulator preview,320] + +=== Live activities + +A live activity is started explicitly from the app and updated by pushing fresh state. The descriptor carries the main content (the lock screen presentation on iOS, the notification layout on Android) plus the Dynamic Island regions: `setCompactLeading` / `setCompactTrailing` for the pill shown around the camera cutout, `setMinimal` for the detached circle when several activities run, and `setExpandedLeading` / `setExpandedTrailing` / `setExpandedCenter` / `setExpandedBottom` for the long-press expanded card. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/surfaces/SurfacesSnippets.java[tag=liveActivity,indent=0] +---- + +The returned handle is inert (`isActive()` returns `false`) on platforms without live activity support, so no platform checks are needed. Updates and the final state are plain state maps interpolated into the descriptor persisted at start time: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/surfaces/SurfacesSnippets.java[tag=liveActivityUpdate,indent=0] +---- + +The simulator preview renders the running activity as a mock Dynamic Island pill plus the expanded card: + +image::img/surfaces-dynamic-island.png[The simulator's mock Dynamic Island pill and expanded live activity card,640] + +On iOS the activity appears on the lock screen and, on supported devices, inside the Dynamic Island (ActivityKit requires iOS 16.1 or newer). On Android it lowers to an ongoing notification that renders the same content; Android 13 and newer prompts for the notification permission, which the build declares for you. On desktop it appears in the simulator preview or as a floating window. + +=== Actions and cold start + +Every interactive node carries a string action id plus an optional parameter map, set with `setAction(id, params)`. Tapping the surface opens the app and delivers a `SurfaceActionEvent` to the single handler registered with `Surfaces.setActionHandler(...)`: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/surfaces/SurfacesSnippets.java[tag=actionHandler,indent=0] +---- + +Actions are delivered on the EDT. When the tap launches a dead app, the event is queued across the cold start and delivered once your handler registers -- which is why the registration belongs in `init()`. `SurfaceActionEvent.isColdStart()` tells you which path you are on, `getSource()` identifies the surface (widget kind or activity type), and `getParams()` returns the parameter map serialized with the action. + +=== Updating from the background + +Surfaces are updated from the running app in this release; push-driven updates are planned. Three mechanisms keep content fresh without keeping the app open: + +*Timelines carry the future with them.* Publish entries covering the hours ahead and the OS flips them on schedule -- the clock above stays correct for an hour without a single wake-up. `WidgetTimeline.RELOAD_AT_END` (the default) asks the platform to request fresh content when the entries run out. + +*Background fetch re-publishes data.* Implement `com.codename1.background.BackgroundFetch`, register with `Display.setPreferredBackgroundFetchInterval(int)`, and re-publish inside the callback -- publishing is data-only file IO, safe from any thread and from a UI-less process: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/surfaces/SurfacesSnippets.java[tag=backgroundFetch,indent=0] +---- + +The completion callback must be invoked, or iOS stops granting fetch time. On iOS, background fetch also needs the `fetch` background mode: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/external-surfaces.properties[tag=external-surfaces-properties-001,indent=0] +---- + +*On Android, the widget pulls the app.* When a provider renders an exhausted `RELOAD_AT_END` timeline (or no timeline at all) it starts the background fetch service directly -- throttled to once per 15 minutes per kind, and only when the app declares background fetch. The iOS widget extension can't wake the app arbitrarily: an exhausted timeline makes WidgetKit re-read the persisted document, so publish timelines with enough future entries to bridge the gap between fetches. The simulator simulates background fetch with a timer that fires while the app is paused (*Simulate > Pause App*). + +=== Per-platform notes + +==== iOS + +The build generates a `CN1Widgets` WidgetKit extension target into the Xcode project and shares published timelines with it through an App Group container. The App Group id defaults to `group.`; override it with the `appGroup` field in `surfaces.json` or the `ios.surfaces.appGroup` build hint. The extension's deployment target defaults to 16.1 (ActivityKit's floor) without raising your app's own deployment target -- on older iOS versions the extension never runs and `Surfaces.areWidgetsSupported()` returns `false`. + +Signing follows the generic app extension rules: the extension needs its own App ID (`.CN1Widgets`) with the App Groups capability and, for cloud device builds with manual signing, its own provisioning profile. Either place the `.mobileprovision` under `common/src/main/resources` or supply a URL with the `ios.appext.CN1Widgets.provisioningURL` build hint. With automatic (managed) signing and in local `ios-source` builds no extra configuration is needed -- Xcode provisions the extension target as usual. + +Widget taps deep link back into the app through the `cn1surface://` URL scheme, which the build registers automatically. + +==== Android + +Widgets are rendered through `RemoteViews` by generated per-kind providers; no Android-specific build hints are needed, and the per-kind sizing metadata comes from `surfaces.json`. Timeline entry flips are scheduled with inexact alarms (a 30-second window) to avoid the exact-alarm permission by default; apps that need to-the-second flips can opt in with the `android.surfaces.exactAlarms` build hint. Second-precision countdowns still tick natively through `Chronometer`. Live activities lower to ongoing notifications. The approximations listed in the node catalog table apply: font weights collapse to regular/bold, circular progress falls back to linear, relative dates refresh only on entry flips, and vector nodes render as bitmaps. + +==== Desktop, Windows, and Linux + +In a desktop build the app shows a tray icon whose menu pins a floating widget per kind: a frameless, always-on-top window rendering the published timeline, with clicks dispatched to your action handler. Window positions and the pinned set persist across runs, and a running live activity docks a pill window at the top of the primary screen. Desktop widgets are process-bound in this release -- they exist while the app process runs. On Windows the plain signed executable ships these layered floating widgets with zero packaging; setting `windows.msix=true` additionally wraps the build in an MSIX package that declares a Windows 11 Widgets Board provider, so your kinds appear in the Win+W board rendered as Adaptive Cards. The MSIX channel is opt-in because it has real distribution prerequisites: a certificate the target machine trusts, the Windows App Runtime redistributable on the target machine, and Windows 11 for the board itself. On Linux the widgets are frameless GTK applet windows; on Wayland compositors that support the layer-shell protocol (KDE Plasma, Sway and the rest of the `wlroots` family) a runtime-loaded `gtk-layer-shell` places widgets above the wallpaper as real desktop applets with persistent positions and drag-to-move, and on GNOME Wayland they degrade to plain floating windows because the compositor controls global positioning and keep-above. + +=== Build hints + +[options="header"] +|=== +| Hint | Default | Purpose +| `ios.surfaces.extension` | `true` | Set to `false` to skip the whole iOS lowering: no extension, no App Group, and the surfaces API reports unsupported at runtime +| `ios.surfaces.appGroup` | `group.` | The shared App Group id; the `appGroup` field in `surfaces.json` takes precedence +| `ios.surfaces.deploymentTarget` | `16.1` | Deployment target of the widget extension (the host app is unaffected) +| `ios.surfaces.frequentUpdates` | `false` | Adds `NSSupportsLiveActivitiesFrequentUpdates` for high-frequency live activity updates +| `ios.appext.CN1Widgets.provisioningURL` | | URL of the extension's provisioning profile for cloud manual-signing builds +| `ios.background_modes` | | Add `fetch` so background fetch can re-publish timelines on device +| `android.surfaces.exactAlarms` | `false` | Schedule widget timeline entry flips with exact alarms; declares `SCHEDULE_EXACT_ALARM` and falls back to the inexact 30-second window when the user revokes the special app access +| `windows.msix` | `false` | Wrap the Windows build in an MSIX package with a Widgets Board provider +| `windows.msix.identityName` | package name | MSIX package identity name +| `windows.msix.publisher` | `CN=` | MSIX identity publisher; must match the signing certificate subject +| `windows.msix.version` | `1.0.0.0` | MSIX package version +| `windows.msix.pfx` / `windows.msix.password` | executable signing configuration | Certificate used to sign the MSIX package +|=== + +=== Current limitations + +* Updates originate from the app (timelines, background fetch, live activity updates). Server-pushed widget content and ActivityKit push tokens are planned; the wire format already accommodates them. +* The node catalog is intentionally the lowest common denominator -- there is no arbitrary per-pixel drawing beyond `SurfaceVector`, and no embedding of regular Codename One components. +* `WidgetSize.LOCKSCREEN` maps to the iOS `accessoryRectangular` family and is ignored on Android in this release. +* The Widgets Board provider requires the `windows.msix` opt-in and its distribution prerequisites; without it, Windows desktop widgets are floating windows. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index a78d414ce04..6e0a1d34ff9 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -89,6 +89,8 @@ include::Push-Notifications.asciidoc[] include::Notifications-And-Background-Execution.asciidoc[] +include::External-Surfaces.asciidoc[] + include::Maps.asciidoc[] include::In-Car-Experiences.asciidoc[] diff --git a/docs/developer-guide/img/surfaces-clock-widget.png b/docs/developer-guide/img/surfaces-clock-widget.png new file mode 100644 index 00000000000..90502301660 Binary files /dev/null and b/docs/developer-guide/img/surfaces-clock-widget.png differ diff --git a/docs/developer-guide/img/surfaces-dynamic-island.png b/docs/developer-guide/img/surfaces-dynamic-island.png new file mode 100644 index 00000000000..8ff87009b4b Binary files /dev/null and b/docs/developer-guide/img/surfaces-dynamic-island.png differ diff --git a/docs/developer-guide/img/surfaces-sample-form.png b/docs/developer-guide/img/surfaces-sample-form.png new file mode 100644 index 00000000000..7bbc87b6f72 Binary files /dev/null and b/docs/developer-guide/img/surfaces-sample-form.png differ diff --git a/docs/developer-guide/img/surfaces-widget-preview.png b/docs/developer-guide/img/surfaces-widget-preview.png new file mode 100644 index 00000000000..bd9018bbd02 Binary files /dev/null and b/docs/developer-guide/img/surfaces-widget-preview.png differ 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 c945eba73c4..12a137ea514 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 @@ -293,6 +293,10 @@ public File getGradleProjectDirectory() { // Set when the app references com.codename1.car.* (Google Android Auto support). Gates the // androidx.car.app gradle dependency, the CarAppService manifest entry and the injected glue. private boolean usesCar; + // Set when the app references com.codename1.surfaces.* (home-screen widgets / live + // 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; private boolean usesOidc; private boolean usesAppleSignIn; private boolean usesWebauthn; @@ -1393,6 +1397,13 @@ public void usesClass(String cls) { usesCar = true; } + // External surfaces (home-screen widgets / live activities). Gated on + // actual usage so the widget receivers, the trampoline activity and the + // pre-baked layout resources are only added for apps that publish surfaces. + if (!usesSurfaces && cls.indexOf("com/codename1/surfaces/") == 0) { + usesSurfaces = true; + } + if (cls.equals("com/codename1/background/ForegroundService")) { usesForegroundService = true; } @@ -2058,6 +2069,162 @@ public void usesClassMethod(String cls, String method) { } } + // 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 + // appwidget-provider metadata. The matching manifest receivers and the tap trampoline + // activity are assembled here and injected into the manifest further below. No gradle + // dependencies are involved -- the runtime lowering is plain RemoteViews in the port. + String surfacesManifestEntries = ""; + if (usesSurfaces) { + File surfacesJsonFile = new File(assetsDir, "surfaces.json"); + if (!surfacesJsonFile.exists()) { + throw new BuildException("This app uses com.codename1.surfaces but no " + + "surfaces.json was found in the project resources. Widget kinds must be " + + "known at build time (the platform widget gallery is compiled into the " + + "app), so add a surfaces.json next to your other resources " + + "(src/main/resources), e.g. {\"liveActivities\":true,\"kinds\":" + + "[{\"id\":\"delivery_status\",\"name\":\"Delivery\"," + + "\"description\":\"Track your order\"}]}"); + } + Map surfacesJson; + try { + JSONParser surfacesParser = new JSONParser(); + surfacesJson = surfacesParser.parseJSON(new InputStreamReader( + new FileInputStream(surfacesJsonFile), StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new BuildException("Failed to parse surfaces.json", ex); + } + // the parser yields Boolean or String depending on its useBoolean setting + Object surfacesLiveActivitiesValue = surfacesJson.get("liveActivities"); + boolean surfacesLiveActivities = Boolean.TRUE.equals(surfacesLiveActivitiesValue) + || "true".equals(surfacesLiveActivitiesValue); + java.util.List surfaceKinds = (java.util.List) surfacesJson.get("kinds"); + if (surfaceKinds == null) { + // legitimate for live-activity-only apps + surfaceKinds = new java.util.ArrayList(); + log("surfaces.json declares no widget kinds; only live activities are available"); + } + + // the pre-baked layouts the generic renderer composes at runtime + String[] surfaceLayouts = { + "cn1_surface_column.xml", "cn1_surface_row.xml", "cn1_surface_box.xml", + "cn1_surface_text.xml", "cn1_surface_chronometer.xml", + "cn1_surface_textclock.xml", "cn1_surface_image.xml", + "cn1_surface_image_fill.xml", "cn1_surface_image_center.xml", + "cn1_surface_progress.xml", "cn1_surface_progress_circular.xml", + "cn1_surface_spacer.xml", "cn1_surface_cell_h.xml", "cn1_surface_cell_v.xml", + "cn1_surface_cell_weight1_h.xml", "cn1_surface_cell_weight1_v.xml" + }; + File surfacesLayoutDir = new File(resDir, "layout"); + surfacesLayoutDir.mkdirs(); + for (String surfaceLayout : surfaceLayouts) { + InputStream lin = getResourceAsStream( + "/com/codename1/builders/surfaces/android/layout/" + surfaceLayout); + if (lin == null) { + throw new BuildException("Missing surfaces layout resource " + surfaceLayout); + } + try { + copy(lin, new FileOutputStream(new File(surfacesLayoutDir, surfaceLayout))); + } catch (IOException ex) { + throw new BuildException("Failed to write surfaces layout " + surfaceLayout, ex); + } + } + File surfacesDrawableDir = new File(resDir, "drawable"); + surfacesDrawableDir.mkdirs(); + InputStream roundedIn = getResourceAsStream( + "/com/codename1/builders/surfaces/android/drawable/cn1_surface_rounded.xml"); + if (roundedIn == null) { + throw new BuildException("Missing surfaces drawable resource cn1_surface_rounded.xml"); + } + try { + copy(roundedIn, new FileOutputStream( + new File(surfacesDrawableDir, "cn1_surface_rounded.xml"))); + } catch (IOException ex) { + throw new BuildException("Failed to write cn1_surface_rounded.xml", ex); + } + + File surfacesImplDir = new File(srcDir, "com/codename1/impl/android"); + surfacesImplDir.mkdirs(); + StringBuilder surfaceReceivers = new StringBuilder(); + for (Object surfaceKindObj : surfaceKinds) { + Map surfaceKind = (Map) surfaceKindObj; + String kindId = surfaceKind.get("id") instanceof String + ? (String) surfaceKind.get("id") : null; + if (kindId == null || !kindId.matches("[a-z][a-z0-9_]*")) { + throw new BuildException("Invalid widget kind id '" + kindId + + "' in surfaces.json; ids must match [a-z][a-z0-9_]*"); + } + String providerClass = "CN1Widget_" + surfaceKindClassSuffix(kindId); + String providerSource = "package com.codename1.impl.android;\n\n" + + "/** Generated by the Codename One build from surfaces.json. */\n" + + "public class " + providerClass + + " extends com.codename1.impl.android.surfaces.CN1WidgetProvider {\n" + + " @Override\n" + + " protected String getKindId() {\n" + + " return \"" + kindId + "\";\n" + + " }\n" + + "}\n"; + try { + createFile(new File(surfacesImplDir, providerClass + ".java"), + providerSource.getBytes(StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new BuildException("Failed to generate " + providerClass, ex); + } + + String providerXml = "\n" + + "\n"; + try { + createFile(new File(xmlDir, "cn1_widget_" + kindId + ".xml"), + providerXml.getBytes(StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new BuildException("Failed to write cn1_widget_" + kindId + ".xml", ex); + } + + String kindLabel = surfaceKind.get("name") instanceof String + ? xmlize((String) surfaceKind.get("name")) : kindId; + surfaceReceivers.append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n"); + } + // the invisible trampoline that turns a widget/live-activity tap into a + // Surfaces.dispatchAction call and brings the main activity forward + surfaceReceivers.append(" \n"); + if ("true".equals(request.getArg("android.surfaces.exactAlarms", "false"))) { + // opt-in exact timeline entry flips: declare the special-access permission + // and surface the choice to the runtime provider through application + // meta-data (read via PackageManager.getApplicationInfo). The provider + // still falls back to inexact setWindow when the user revokes the access. + surfaceReceivers.append(" \n"); + permissions += permissionAdd(request, "\"android.permission.SCHEDULE_EXACT_ALARM\"", + " \n"); + } + surfacesManifestEntries = surfaceReceivers.toString(); + if (surfacesLiveActivities) { + // live activities lower to ongoing notifications; Android 13+ needs the + // runtime permission declared (permissionAdd dedups against user overrides) + postNotificationsPermission = true; + } + } + // We need to choose the correct PlayServices class file for the version of play services // we are building for. @@ -3178,6 +3345,7 @@ public void usesClassMethod(String cls, String method) { + remoteControlService + hceService + carAppService + + surfacesManifestEntries + " \n" + " \n" + basePermissions @@ -3258,6 +3426,14 @@ public void usesClassMethod(String cls, String method) { + " com.codename1.impl.android.AndroidImplementation.setCurrentApplicationInstance(i);\n" + " com.codename1.impl.android.AndroidImplementation.deliverPendingSharedContent();\n"; + if (usesSurfaces) { + // flush surface actions (widget/live-activity taps) that arrived through the + // CN1SurfaceActionActivity trampoline before the app instance existed; conditional + // so apps that never touch com.codename1.surfaces keep it strippable + localNotificationCode + += " com.codename1.impl.android.AndroidImplementation.deliverPendingSurfaceActions();\n"; + } + // Install the build-time-generated @Route dispatcher before the // first Display init. The reinit branch doesn't repeat the call @@ -4375,6 +4551,13 @@ public void usesClassMethod(String cls, String method) { + " public "+xclass("android.support.v4.app.NotificationCompat")+"$Builder setChannelId(java.lang.String);\n" + "}\n\n" + "-keep class **Stub { *; }\n\n" // Because there have been cases where release builds were stripping out native interfaces + // Surfaces: the generated widget providers are resolved by name from the + // manifest and from the class-name convention in AndroidSurfaceBridge, and the + // runtime package is reached from generated code; neither may be renamed. + + (usesSurfaces + ? "-keep class com.codename1.impl.android.CN1Widget_* { *; }\n\n" + + "-keep class com.codename1.impl.android.surfaces.** { *; }\n\n" + : "") + facebookProguard + " " + request.getArg("android.proguardKeep", "") + "\n" + googlePlayObfuscation @@ -4976,6 +5159,42 @@ static String xmlize(String s) { return s; } + /** + * Maps a widget kind id to the simple name suffix of its generated provider class: + * underscore-separated words become CamelCase ({@code delivery_status} -> + * {@code DeliveryStatus}). The identical logic lives in + * {@code com.codename1.impl.android.surfaces.AndroidSurfaceBridge#toClassSuffix} in the + * Android port; keep them in sync. + */ + static String surfaceKindClassSuffix(String kindId) { + StringBuilder sb = new StringBuilder(kindId.length()); + boolean upper = true; + for (int i = 0; i < kindId.length(); i++) { + char c = kindId.charAt(i); + if (c == '_') { + upper = true; + continue; + } + if (upper) { + sb.append(Character.toUpperCase(c)); + upper = false; + } else { + sb.append(c); + } + } + return sb.toString(); + } + + /** + * Formats a numeric surfaces.json value (the JSON parser produces Doubles) as a dp integer + * string, falling back to the supplied default when absent or malformed. + */ + static String surfaceDp(Object value, String defaultValue) { + if (value instanceof Number) { + return String.valueOf(((Number) value).intValue()); + } + return defaultValue; + } @Override protected String registerNativeImplementationsAndCreateStubs(ClassLoader parentClassLoader, File stubDir, File... classesDirectory) throws MalformedURLException, IOException { 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 0cabd385f21..dfaa1b642cc 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 @@ -23,6 +23,7 @@ package com.codename1.builders; import com.codename1.util.IOSWalletExtensionBuilder; +import com.codename1.util.IOSWidgetExtensionBuilder; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; @@ -119,6 +120,20 @@ public class IPhoneBuilder extends Executor { // CN1_USE_CARPLAY native define, CarPlay.framework linkage, the carplay entitlement and the // CarPlay scene in the Info.plist scene manifest. Apps that never touch the API see no change. private boolean usesCar; + // Set when the app references com.codename1.surfaces.* (home-screen widgets + live + // activities). Gates the CN1_USE_WIDGETS native define, the CN1Widgets WidgetKit extension + // target, the app group / CN1SurfacesAppGroup + NSSupportsLiveActivities plist injection and + // the cn1surface URL scheme. Apps that never touch the API see no change. + private boolean usesSurfaces; + // usesSurfaces && ios.surfaces.extension != false. When the developer opts out with + // ios.surfaces.extension=false the whole iOS lowering is skipped (no define flip, no + // extension, no Swift glue): the surfaces API compiles but answers unsupported at runtime. + private boolean surfacesExtensionEnabled; + // Resolved app group: surfaces.json appGroup > ios.surfaces.appGroup hint > group.. + private String surfacesAppGroup; + private boolean surfacesLiveActivities; + private final List surfacesKinds = + new ArrayList(); private boolean usesOidc; private boolean usesAppleSignIn; private boolean usesWebauthn; @@ -842,6 +857,13 @@ public void usesClass(String cls) { if (!usesCar && cls.indexOf("com/codename1/car/") == 0) { usesCar = true; } + // External surfaces (com.codename1.surfaces.*): home-screen widgets + // and live activities. Gated on actual usage so the CN1Widgets + // extension / app group / CN1_USE_WIDGETS natives are only added for + // apps that publish external surfaces. + if (!usesSurfaces && cls.indexOf("com/codename1/surfaces/") == 0) { + usesSurfaces = true; + } // OidcClient + SystemBrowser rely on // ASWebAuthenticationSession (AuthenticationServices.framework, // iOS 12+). @@ -902,6 +924,11 @@ public void usesClassMethod(String cls, String method) { } stopwatch.split("Scan Classes"); + // External surfaces: parse the build-time kinds manifest (surfaces.json in the project + // resources, delivered alongside .ios.appext archives in resDir) and resolve the app + // group. Widget kinds must be known at build time -- the Swift WidgetBundle is static. + parseSurfacesManifest(resDir, request); + // Apply AI/ML dependency table hits accumulated during the // scan. We route iOS pods through the existing // iosPods string and SPM entries through the request build @@ -1998,6 +2025,15 @@ public void usesClassMethod(String cls, String method) { replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_CARPLAY", "#define CN1_USE_CARPLAY"); } + // com.codename1.surfaces usage compiles the WidgetKit/ActivityKit native glue (gated + // by CN1_USE_WIDGETS so other builds carry no surfaces symbols). The define lives in + // the shared CodenameOne_GLViewController.h so it is visible to every surfaces + // translation unit (IOSNative.m and CodenameOne_GLAppDelegate.m), mirroring + // CN1_USE_CARPLAY. Skipped when ios.surfaces.extension=false. + if (surfacesExtensionEnabled) { + replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_WIDGETS", "#define CN1_USE_WIDGETS"); + } + String glAppDelegeateBody = request.getArg("ios.glAppDelegateBody", null); if (glAppDelegeateBody != null && glAppDelegeateBody.length() > 0) { replaceInFile(glAppDelegate, "//GL_APP_DELEGATE_BODY", glAppDelegeateBody); @@ -2357,6 +2393,20 @@ public void usesClassMethod(String cls, String method) { } } + // External surfaces: the app target needs the shared App Group in its own + // entitlements (the CN1Widgets extension gets it through its generated + // .entitlements file). ios.app_groups is the established comma-separated hint the + // cloud builder's entitlement generator consumes; local Xcode builds supply the app + // entitlements externally via $(APP_CODE_SIGN_ENTITLEMENTS), matching the wallet + // extension flow. + if (surfacesExtensionEnabled) { + String appGroups = request.getArg("ios.app_groups", ""); + if (!appGroups.contains(surfacesAppGroup)) { + request.putArgument("ios.app_groups", appGroups.length() == 0 + ? surfacesAppGroup : appGroups + "," + surfacesAppGroup); + } + } + // Sign in with Apple requires the // com.apple.developer.applesignin entitlement; Apple rejects // builds whose binary references ASAuthorizationAppleIDProvider @@ -2829,9 +2879,10 @@ public void usesClassMethod(String cls, String method) { return false; } } - // Wallet extensions and .ios.appext archives mutate the Xcode project through the - // ruby xcodeproj gem even when CocoaPods isn't otherwise needed. - boolean needsXcodeProjectMutation = runPods || walletExtensionEnabled || hasAppExtensionArchives(resDir); + // Wallet/widget extensions and .ios.appext archives mutate the Xcode project through + // the ruby xcodeproj gem even when CocoaPods isn't otherwise needed. + boolean needsXcodeProjectMutation = runPods || walletExtensionEnabled + || surfacesExtensionEnabled || hasAppExtensionArchives(resDir); if (needsXcodeProjectMutation) { try { List podSpecFileList = new ArrayList(); @@ -2866,6 +2917,12 @@ public void usesClassMethod(String cls, String method) { + " config.build_settings['SWIFT_OBJC_BRIDGING_HEADER']='$(SRCROOT)/cn1-Bridging-Header.h'\n" + " }\n" + " xcproj.targets.each do |target|\n" + + " # App-extension targets (CN1Widgets, wallet issuer provisioning) own their\n" + + " # deployment targets. This script re-runs after pods integration, and on the\n" + + " # second pass the extension targets already exist -- without this skip the\n" + + " # pass stomps them down to the app's deployment target (seen as WidgetKit\n" + + " # sources compiling at iOS 14 instead of the extension's 16.1).\n" + + " next if target.respond_to?(:product_type) && target.product_type == 'com.apple.product-type.app-extension'\n" + " target.build_configurations.each do |config|\n" + " config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '" + getDeploymentTarget(request) + "'\n" + " end\n" @@ -2998,6 +3055,14 @@ public void usesClassMethod(String cls, String method) { appendWalletExtensionTargets(appExtensionsBuilder, request, new File(tmpFile, "dist")); } + if (surfacesExtensionEnabled) { + // appExtensionsBuilder is appended to the schemes script AFTER the + // global deployment-target pass (deploymentTargetStr), so the + // extension's IPHONEOS_DEPLOYMENT_TARGET=16.1 survives it -- see the + // ordering note in appendWidgetExtensionRuby. + appendWidgetExtensionTargets(appExtensionsBuilder, request, new File(tmpFile, "dist")); + } + String installLocalizedStrings = ""; if (installLocalizedStringsScript.length() > 0) { installLocalizedStrings = "begin\n"+ @@ -3051,9 +3116,14 @@ public void usesClassMethod(String cls, String method) { + " rescue\n" + " end\n" + " end\n" + // The CN1Widgets extension's Swift sources must never be swept into + // the APP target: on script re-runs (post dependency integration) + // the extension group already exists and this catch-all would + // otherwise add WidgetKit sources to the app's compile phase. + " swift_refs = xcproj.files.select do |f|\n" + " file_name = f.path || f.name || f.display_name\n" - + " file_name && file_name.downcase.end_with?('.swift')\n" + + " file_name && file_name.downcase.end_with?('.swift') && !file_name.start_with?('" + + SURFACES_EXTENSION_NAME + "/')\n" + " end\n" + " swift_refs.each do |ref|\n" + " unless main_target.source_build_phase.files_references.include?(ref)\n" @@ -3862,6 +3932,213 @@ private void appendWalletExtensionRuby(StringBuilder sb, BuildRequest request, S sb.append("end\n"); } + /** Xcode target / folder name of the generated WidgetKit extension. */ + static final String SURFACES_EXTENSION_NAME = "CN1Widgets"; + + /** + * Parses the surfaces.json build-time manifest ({appGroup?, liveActivities?, kinds:[{id, + * name, description, iosFamilies?, preview?}]}) and resolves the app group. Fails the + * build loudly when the app uses com.codename1.surfaces without a manifest -- the widget + * gallery is compiled into the native app, so kinds cannot be registered at runtime only. + */ + @SuppressWarnings("unchecked") + private void parseSurfacesManifest(File resDir, BuildRequest request) throws BuildException { + surfacesExtensionEnabled = usesSurfaces + && !"false".equals(request.getArg("ios.surfaces.extension", "true")); + if (!surfacesExtensionEnabled) { + // Either the app never touches com.codename1.surfaces, or the developer opted out + // with ios.surfaces.extension=false. In both cases the iOS lowering is skipped + // entirely (no CN1_USE_WIDGETS flip, no extension target, no plist keys): the + // surfaces API stays an inert no-op at runtime. + return; + } + File manifest = new File(resDir, "surfaces.json"); + if (!manifest.exists()) { + throw new BuildException("This app uses com.codename1.surfaces but the project has " + + "no surfaces.json resource. Widget kinds must be declared at build time; " + + "add surfaces.json to src/main/resources, e.g.\n" + + "{\"appGroup\": \"group." + request.getPackageName() + "\",\n" + + " \"liveActivities\": true,\n" + + " \"kinds\": [{\"id\": \"delivery\", \"name\": \"Delivery\", " + + "\"description\": \"Track your order\", " + + "\"iosFamilies\": [\"small\", \"medium\"]}]}\n" + + "or disable the iOS widget extension with ios.surfaces.extension=false."); + } + Map parsed; + try (InputStreamReader reader = new InputStreamReader( + new FileInputStream(manifest), StandardCharsets.UTF_8)) { + parsed = new com.codename1.builders.util.JSONParser().parseJSON(reader); + } catch (IOException ex) { + throw new BuildException("Failed to parse surfaces.json", ex); + } + String manifestAppGroup = parsed.get("appGroup") instanceof String + ? (String) parsed.get("appGroup") : null; + String hintAppGroup = request.getArg("ios.surfaces.appGroup", null); + surfacesAppGroup = manifestAppGroup != null && manifestAppGroup.length() > 0 + ? manifestAppGroup + : (hintAppGroup != null && hintAppGroup.length() > 0 + ? hintAppGroup : "group." + request.getPackageName()); + if (!surfacesAppGroup.startsWith("group.")) { + throw new BuildException("The surfaces app group must start with 'group.' (Apple " + + "requirement); found '" + surfacesAppGroup + "' (from surfaces.json " + + "appGroup or the ios.surfaces.appGroup build hint)"); + } + Object liveActivities = parsed.get("liveActivities"); + surfacesLiveActivities = Boolean.TRUE.equals(liveActivities) + || "true".equals(liveActivities); + surfacesKinds.clear(); + Object kinds = parsed.get("kinds"); + if (kinds instanceof List) { + for (Object rawKind : (List) kinds) { + if (!(rawKind instanceof Map)) { + continue; + } + Map kindMap = (Map) rawKind; + Object id = kindMap.get("id"); + if (!(id instanceof String) || !((String) id).matches("[a-z][a-z0-9_]*")) { + throw new BuildException("surfaces.json widget kind ids must match " + + "[a-z][a-z0-9_]*; found: " + id); + } + IOSWidgetExtensionBuilder.Kind kind = + new IOSWidgetExtensionBuilder.Kind((String) id); + if (kindMap.get("name") instanceof String) { + kind.setName((String) kindMap.get("name")); + } + if (kindMap.get("description") instanceof String) { + kind.setDescription((String) kindMap.get("description")); + } + if (kindMap.get("preview") instanceof String) { + kind.setPreviewName((String) kindMap.get("preview")); + } + if (kindMap.get("iosFamilies") instanceof List) { + List families = new ArrayList(); + for (Object family : (List) kindMap.get("iosFamilies")) { + if (family instanceof String) { + families.add((String) family); + } + } + kind.setIosFamilies(families); + } + surfacesKinds.add(kind); + } + } + if (surfacesKinds.isEmpty() && !surfacesLiveActivities) { + throw new BuildException("surfaces.json declares neither widget kinds nor " + + "\"liveActivities\": true; there is nothing to build"); + } + // The extension is wired into the Xcode project through the ruby xcodeproj gem; + // fail early with a friendly message when it is missing. + ensureXcodeprojInstalled(); + log("External surfaces enabled: app group " + surfacesAppGroup + ", " + + surfacesKinds.size() + " widget kind(s)" + + (surfacesLiveActivities ? ", live activities" : "")); + } + + /** + * Generates the CN1Widgets WidgetKit extension folder under dist/, drops the app-side + * Swift glue into <MainClass>-src (the schemes ruby sweeps *.swift there into the + * APP target) and appends the ruby that wires the extension target into the generated + * Xcode project. Modeled on {@link #appendWalletExtensionTargets}. + */ + private void appendWidgetExtensionTargets(StringBuilder sb, BuildRequest request, File distDir) throws IOException { + IOSWidgetExtensionBuilder widgetBuilder = new IOSWidgetExtensionBuilder() + .setExtensionName(SURFACES_EXTENSION_NAME) + .setHostBundleId(request.getPackageName()) + .setAppGroupId(surfacesAppGroup) + .setDeploymentTarget(request.getArg("ios.surfaces.deploymentTarget", "16.1")) + .setLiveActivitiesEnabled(surfacesLiveActivities); + for (IOSWidgetExtensionBuilder.Kind kind : surfacesKinds) { + widgetBuilder.addKind(kind); + } + String extensionName = widgetBuilder.getExtensionName(); + File extensionDir = new File(distDir, extensionName); + IOSWalletExtensionBuilder.writeFileMap(widgetBuilder.buildFileMap(), extensionDir); + // App-target glue: the Swift CN1SurfaceBridge (reached from IOSNative.m via + // NSClassFromString), the shared ActivityAttributes struct and the app group + // constant. Written into -src so the schemes script compiles them + // into the APP target, not the extension. + IOSWalletExtensionBuilder.writeFileMap(widgetBuilder.buildAppTargetFileMap(), + new File(distDir, request.getMainClass() + "-src")); + appendWidgetExtensionRuby(sb, request, widgetBuilder, extensionDir, distDir); + log("Adding WidgetKit extension target " + extensionName + " (" + + surfacesKinds.size() + " widget kind(s)" + + (surfacesLiveActivities ? " + live activities" : "") + ")"); + sb.append("xcproj.save(project_file)\n"); + } + + private void appendWidgetExtensionRuby(StringBuilder sb, BuildRequest request, + IOSWidgetExtensionBuilder widgetBuilder, File extensionDir, File distDir) throws IOException { + String extensionName = widgetBuilder.getExtensionName(); + Map buildSettingsMap = new LinkedHashMap(); + buildSettingsMap.put("PRODUCT_NAME", "$(TARGET_NAME)"); + buildSettingsMap.put("TARGETED_DEVICE_FAMILY", "1,2"); + buildSettingsMap.put("LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"); + buildSettingsMap.put("CLANG_ENABLE_MODULES", "YES"); + // The builder's buildSettings.properties supplies the deployment target, Swift + // version, bundle id, entitlements and Info.plist paths; deleted after loading so + // it is not added to the Xcode group as a resource. + File buildSettingsProps = new File(extensionDir, "buildSettings.properties"); + if (buildSettingsProps.exists()) { + Properties props = new Properties(); + try (FileInputStream fis = new FileInputStream(buildSettingsProps)) { + props.load(fis); + } + for (Object key : props.keySet()) { + if (key instanceof String) { + buildSettingsMap.put((String) key, props.getProperty((String) key)); + } + } + buildSettingsProps.delete(); + } + for (String key : request.getArgs()) { + if (key.startsWith("ios.surfaces.buildSettings.")) { + buildSettingsMap.put(key.substring("ios.surfaces.buildSettings.".length()), + request.getArg(key, "")); + } + } + // CRITICAL ordering note: this fragment rides in appExtensionsBuilder, which the + // schemes script appends AFTER the global deployment-target pass + // (deploymentTargetStr stomps IPHONEOS_DEPLOYMENT_TARGET on every existing target). + // Because this target is created after that pass ran, its 16.1 deployment target + // below survives. Do not move this fragment before deploymentTargetStr. + // The whole fragment is guarded so re-running the script (the build re-executes + // fix_xcode_schemes.rb after dependency integration) doesn't duplicate the target. + // No explicit framework linkage: Swift autolinks WidgetKit/ActivityKit (weakly + // where guarded by canImport/@available). + sb.append("\nif xcproj.targets.find{|e| e.name=='" + extensionName + "'}.nil?\n" + + "service_target = xcproj.new_target(:app_extension, '" + extensionName + "', :ios, '" + + widgetBuilder.getDeploymentTarget() + "')\n" + + "service_group = xcproj.new_group('" + extensionName + "')\n"); + appendFilesToXcodeProjGroup(sb, extensionDir, "service_group", "service_target", distDir); + sb.append("main_app_target = xcproj.targets.find{|e| e.name==main_class_name}\n" + + "main_app_target.add_dependency(service_target)\n" + + "fileref = xcproj.groups.find{|e| e.display_name=='Products'}.new_file('" + extensionName + ".appex', \"BUILT_PRODUCTS_DIR\")\n" + + "embed_phase = main_app_target.copy_files_build_phases.find{|p| p.name=='Embed App Extensions'} || main_app_target.new_copy_files_build_phase('Embed App Extensions')\n" + + "embed_phase.build_action_mask = \"2147483647\"\n" + + "embed_phase.dst_subfolder_spec = \"13\"\n" + + "embed_phase.run_only_for_deployment_postprocessing=\"0\"\n" + + "embed_file = embed_phase.add_file_reference(fileref)\n"); + if (macNativeBuilder.isEnabled()) { + // Mac Catalyst v1 guard: this iOS build also produces a Mac Catalyst slice + // (macNative.enabled=true), but the CN1Widgets extension is iOS-only in v1 -- + // MacNativeBuilder marks only the APP target SUPPORTS_MACCATALYST and its Mac + // entitlements carry no app group, so building/embedding the extension for the + // Mac destination would fail the Catalyst archive. Platform-filter the target + // dependency and the embed step to the iOS slice: the iOS app keeps its widgets + // and live activities, the Mac slice simply ships without them. + sb.append("dep = main_app_target.dependencies.find{|d| d.target && d.target.uuid == service_target.uuid}\n" + + "dep.platform_filter = 'ios' if dep\n" + + "embed_file.platform_filter = 'ios'\n"); + buildSettingsMap.put("SUPPORTS_MACCATALYST", "NO"); + } + sb.append("service_target.build_configurations.each{|e| \n"); + for (String buildSettingKey : buildSettingsMap.keySet()) { + sb.append(" e.build_settings['" + buildSettingKey + "'] = \"" + buildSettingsMap.get(buildSettingKey) + "\"\n"); + } + sb.append("}\n"); + sb.append("end\n"); + } + private File[] extractAppExtensions(File sourceDirectory, File targetDirectory) throws IOException { if (sourceDirectory == null || !sourceDirectory.isDirectory()) { throw new IllegalArgumentException("extractAppExtensions sourceDirectory must be an existing directory but received "+sourceDirectory); @@ -4262,6 +4539,40 @@ public boolean accept(File file, String string) { inject += "\nCN1WalletAppGroup" + walletAppGroup.trim() + ""; } + // External surfaces: the Java bridge (IOSSurfaceBridge via IOSNative.m) resolves the + // shared App Group container through this key; the CN1Widgets extension carries its own + // copy in its generated Info.plist. See surfaces.json / the ios.surfaces.* build hints. + if (surfacesExtensionEnabled) { + if (!inject.contains("CN1SurfacesAppGroup")) { + inject += "\nCN1SurfacesAppGroup" + surfacesAppGroup + ""; + } + // The extension's deployment target: the runtime gates areWidgetsSupported() on it + // (the extension cannot run or appear in the widget gallery below this version, so + // WidgetKit's own iOS 14 floor is not the right check). + if (!inject.contains("CN1SurfacesMinOS")) { + inject += "\nCN1SurfacesMinOS" + + request.getArg("ios.surfaces.deploymentTarget", "16.1") + ""; + } + // NSSupportsLiveActivities belongs in the HOST app's Info.plist (the extension only + // renders them). Intentionally skipped when ios.surfaces.extension=false: without + // the extension ActivityKit would accept the request but show nothing. + if (surfacesLiveActivities && !inject.contains("NSSupportsLiveActivities")) { + inject += "\nNSSupportsLiveActivities"; + if ("true".equals(request.getArg("ios.surfaces.frequentUpdates", "false"))) { + inject += "\nNSSupportsLiveActivitiesFrequentUpdates"; + } + } + // Widget taps deep link back through cn1surface:// (handled by the app delegate and + // never stored in AppArg). Register the scheme by appending to ios.urlSchemes so it + // rides the existing CFBundleURLTypes injection below, whichever branch runs. + if (!inject.contains("cn1surface")) { + String urlSchemes = request.getArg("ios.urlSchemes", request.getArg("ios.urlScheme", "")); + if (!urlSchemes.contains("cn1surface")) { + request.putArgument("ios.urlSchemes", urlSchemes + "cn1surface"); + } + } + } + BufferedReader infoReader = new BufferedReader(new InputStreamReader( Files.newInputStream(infoPlist.toPath()), StandardCharsets.UTF_8)); StringBuilder b = new StringBuilder(); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java index 5f820d74fff..b1c1ba5df34 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java @@ -305,6 +305,17 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException boolean cross = !is_windows; File xwinSysroot = cross ? resolveXwinSysroot(request) : null; + // windows.msix=true additionally compiles the Windows 11 Widgets Board + // COM provider (cn1_windows_widgetboard.cpp, gated on CN1_WIDGETBOARD) + // into the exe and, after signing, packs an MSIX around it -- see + // buildMsixPackage below. The define is injected through the compile + // flags the builder already passes to CMake (the least invasive hook; + // the translator-generated CMakeLists is untouched), so plain builds + // are unaffected. + boolean msix = "true".equalsIgnoreCase(request.getArg("windows.msix", "false")); + File winAppSdk = msix ? resolveWinAppSdkDir() : null; + String widgetBoardCompileFlags = msix ? widgetBoardCompileFlags(winAppSdk) : ""; + List configure = new ArrayList(); configure.add("cmake"); configure.add("-S"); @@ -317,10 +328,22 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException configure.add("-DCMAKE_C_COMPILER=" + clangClExecutable()); configure.add("-DCMAKE_CXX_COMPILER=" + clangClExecutable()); if (cross) { - addCrossCompileConfigure(configure, triple, arch, xwinSysroot); + addCrossCompileConfigure(configure, triple, arch, xwinSysroot, + widgetBoardCompileFlags, msix ? widgetBoardLinkFlags(winAppSdk, arch) : ""); + if (msix) { + // same C++20 requirement as the Windows-host branch below + configure.add("-DCMAKE_CXX_STANDARD=20"); + } } else { - configure.add("-DCMAKE_C_FLAGS=--target=" + triple); - configure.add("-DCMAKE_CXX_FLAGS=--target=" + triple); + configure.add("-DCMAKE_C_FLAGS=--target=" + triple + widgetBoardCompileFlags); + configure.add("-DCMAKE_CXX_FLAGS=--target=" + triple + widgetBoardCompileFlags); + if (msix) { + configure.add("-DCMAKE_EXE_LINKER_FLAGS=" + widgetBoardLinkFlags(winAppSdk, arch).trim()); + // C++/WinRT at C++17 falls back to , which the + // modern MSVC STL rejects under clang-cl; the generated CMakeLists only + // defaults CMAKE_CXX_STANDARD when it is not supplied here. + configure.add("-DCMAKE_CXX_STANDARD=20"); + } } try { @@ -354,6 +377,9 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException // is supplied; otherwise it ships unsigned (which runs, but trips SmartScreen // / "Unknown publisher"). signWindowsExecutable(windowsExecutable, request); + // windows.msix=true: wrap the signed exe in an MSIX package that also + // declares the Windows 11 Widgets Board provider (surfaces.json kinds). + buildMsixPackage(request, resDir, arch); log("Native Windows executable: " + windowsExecutable.getAbsolutePath() + " (" + arch + ")"); return true; } @@ -379,7 +405,7 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException * * *

Hardware-backed / cloud keys (the post-2023 CA/B requirement -- Azure Trusted - * Signing, DigiCert KeyLocker, …) are reached by pointing the above at a + * Signing, DigiCert KeyLocker, ...) are reached by pointing the above at a * PKCS#11-fronted credential per the signing service's docs; the command shape is * the same.

*/ @@ -470,6 +496,467 @@ private void signWindowsExecutable(File exe, BuildRequest request) { } } + /* -------------------------------------------------------------------------- + * MSIX packaging + Windows 11 Widgets Board (windows.msix=true) + * -------------------------------------------------------------------------- + * Two distribution channels exist for the native Windows target: + * + * 1. The plain signed exe (the default). Users get the floating layered + * widget windows (cn1_windows_widgets.cpp / WindowsWidgetBridge) -- + * always-on-top desktop widgets that work with zero packaging. + * + * 2. windows.msix=true: the exe is additionally wrapped in an MSIX + * package that declares a Windows 11 Widgets Board provider -- real + * widgets in the Win+W board, rendered from Adaptive Cards mapped off + * the same persisted surface descriptors. This channel has hard + * distribution prerequisites, which is why it is opt-in: + * - the MSIX must be signed with a certificate the TARGET machine + * trusts (a self-signed cert means dev-sideloading only; store or + * EV/organization certs for real distribution); + * - the Windows App SDK runtime (WindowsAppRuntime redistributable, + * 1.5 series) must be installed on the target machine -- the + * provider binds it at startup via MddBootstrapInitialize; + * - Widgets Board exists on Windows 11 only (older Windows installs + * the MSIX fine but shows no widgets). + * + * Build-time requirements when windows.msix=true: + * - CN1_WINAPPSDK_DIR must point at a Windows App SDK layout with + * include/ (C++/WinRT projections + MddBootstrap.h), lib// + * or the raw nupkg's lib/win10-/ + * (Microsoft.WindowsAppRuntime.Bootstrap.lib) and optionally + * bin// or runtimes/win10-/native/ + * Microsoft.WindowsAppRuntime.Bootstrap.dll (copied into the package + * next to the exe). Note the Microsoft.WindowsAppSDK NuGet package + * ships only winmd metadata, NOT prebuilt C++/WinRT headers: generate + * the projections into include/ with cppwinrt.exe (the + * Microsoft.Windows.CppWinRT package), e.g. + * cppwinrt -in lib/uap10.0/Microsoft.Windows.Widgets.winmd -in local + * -output include + * -- the same recipe the widgetboard-compile-check CI job uses + * (.github/workflows/parparvm-tests-windows.yml). Paths must not + * contain spaces (they travel through CMake flag strings). + * - `makemsix` (the cross-platform msix-packaging tool) must be on PATH + * (CN1_MAKEMSIX overrides), so the Linux build daemon can pack without + * a Windows host. + * + * Hints: windows.msix.identityName / windows.msix.publisher / + * windows.msix.version (default 1.0.0.0) fill the package Identity; + * windows.msix.pfx / windows.msix.password sign the package (falling back + * to the exe-signing configuration). Widget definitions are derived from + * the project's surfaces.json kinds; without a surfaces.json the MSIX is + * produced with no widget extension (plain packaged app). + * ------------------------------------------------------------------------ */ + + /** + * The COM class id of the widget provider ExeServer. Must match + * {@code CN1_WIDGET_PROVIDER_CLSID} in + * {@code Ports/WindowsPort/nativeSources/cn1_windows_widgetboard.cpp}. + */ + static final String WIDGET_PROVIDER_CLSID = "C0DE4A11-5A2F-4E7B-9C61-7D1B4A0C8E52"; + + /** + * Resolves the Windows App SDK layout used to compile the Widgets Board + * provider ({@code CN1_WINAPPSDK_DIR}); required when {@code windows.msix=true}. + */ + private File resolveWinAppSdkDir() { + String path = System.getenv("CN1_WINAPPSDK_DIR"); + if (path == null || path.isEmpty()) { + throw new BuildException("windows.msix=true compiles the Windows 11 Widgets Board provider, " + + "which needs the Windows App SDK headers and libs. Point the CN1_WINAPPSDK_DIR " + + "environment variable at a layout containing include/ (C++/WinRT projections + " + + "MddBootstrap.h) and lib// (or the raw nupkg's lib/win10-/) " + + "Microsoft.WindowsAppRuntime.Bootstrap.lib. Extract the layout from the " + + "Microsoft.WindowsAppSDK NuGet package, then generate the C++/WinRT projection " + + "headers into include/ with cppwinrt.exe (Microsoft.Windows.CppWinRT package): " + + "cppwinrt -in lib/uap10.0/Microsoft.Windows.Widgets.winmd -in local -output include"); + } + File dir = new File(path); + if (!new File(dir, "include").isDirectory()) { + throw new BuildException("CN1_WINAPPSDK_DIR does not look like a Windows App SDK layout " + + "(missing include/): " + dir.getAbsolutePath()); + } + return dir; + } + + /** clang-cl flags enabling the Widgets Board provider compile (CN1_WIDGETBOARD). */ + private static String widgetBoardCompileFlags(File winAppSdk) { + return " /DCN1_WIDGETBOARD=1 /I" + new File(winAppSdk, "include").getAbsolutePath(); + } + + /** + * Linker flags adding the WindowsAppRuntime bootstrap import library. + * Accepts both the documented lib/<arch>/ layout and the raw NuGet + * package's lib/win10-<arch>/ layout, so an extracted + * Microsoft.WindowsAppSDK nupkg works as CN1_WINAPPSDK_DIR without + * re-arranging directories. + */ + private static String widgetBoardLinkFlags(File winAppSdk, String arch) { + String normalized = normalizeArch(arch); + File libDir = new File(winAppSdk, "lib/" + normalized); + if (!new File(libDir, "Microsoft.WindowsAppRuntime.Bootstrap.lib").isFile()) { + File nupkgLayout = new File(winAppSdk, "lib/win10-" + normalized); + if (new File(nupkgLayout, "Microsoft.WindowsAppRuntime.Bootstrap.lib").isFile()) { + libDir = nupkgLayout; + } + } + return " /libpath:" + libDir.getAbsolutePath() + " Microsoft.WindowsAppRuntime.Bootstrap.lib"; + } + + /** + * Packs the built (already signed) exe into an MSIX declaring the COM + * ExeServer + {@code com.microsoft.windows.widgets} app extension, then + * signs the package. See the block comment above for the channel + * requirements. No-op unless {@code windows.msix=true}. + */ + private void buildMsixPackage(BuildRequest request, File resDir, String arch) { + if (!"true".equalsIgnoreCase(request.getArg("windows.msix", "false"))) { + return; + } + log("Packaging MSIX (windows.msix=true) with Widgets Board provider"); + try { + File layout = new File(getBuildDirectory(), "msix-layout"); + File assets = new File(layout, "Assets"); + File publicDir = new File(layout, "Public"); + layout.mkdirs(); + assets.mkdirs(); + publicDir.mkdirs(); + + // The manifest's Executable/ComServer reference "app.exe" so the + // COM activation command line is stable regardless of main class. + copy(windowsExecutable, new File(layout, "app.exe")); + + // The provider binds the WindowsAppRuntime via MddBootstrapInitialize, + // which needs Microsoft.WindowsAppRuntime.Bootstrap.dll next to the + // exe. Copy it out of the SDK layout when present (either the + // documented bin// layout or the raw nupkg's + // runtimes/win10-/native/); otherwise the developer must add + // it to the package manually. + File winAppSdk = resolveWinAppSdkDir(); + File bootstrapDll = new File(winAppSdk, + "bin/" + normalizeArch(arch) + "/Microsoft.WindowsAppRuntime.Bootstrap.dll"); + if (!bootstrapDll.isFile()) { + File nupkgDll = new File(winAppSdk, "runtimes/win10-" + normalizeArch(arch) + + "/native/Microsoft.WindowsAppRuntime.Bootstrap.dll"); + if (nupkgDll.isFile()) { + bootstrapDll = nupkgDll; + } + } + if (bootstrapDll.isFile()) { + copy(bootstrapDll, new File(layout, "Microsoft.WindowsAppRuntime.Bootstrap.dll")); + } else { + log("Warning: " + bootstrapDll.getAbsolutePath() + " not found; add " + + "Microsoft.WindowsAppRuntime.Bootstrap.dll to the MSIX next to app.exe " + + "or the widget provider will fail to bind the WindowsAppRuntime."); + } + + // Widget definition icons/screenshots + package logos all reuse the + // app icon; Windows scales them (pixel-perfect per-scale assets can + // be a follow-up). + byte[] icon = request.getIcon(); + if (icon == null || icon.length == 0) { + throw new BuildException("windows.msix=true needs the app icon to generate the MSIX " + + "logo assets, but the build request carries no icon."); + } + createFile(new File(assets, "StoreLogo.png"), icon); + createFile(new File(assets, "Square150x150Logo.png"), icon); + createFile(new File(assets, "Square44x44Logo.png"), icon); + createFile(new File(assets, "WidgetScreenshot.png"), icon); + // uap3:AppExtension requires a public folder; ship a marker file so + // packers that skip empty directories keep it. + createFile(new File(publicDir, "readme.txt"), + "Codename One widget provider public folder".getBytes(StandardCharsets.UTF_8)); + + String widgetDefinitions = buildWidgetDefinitionsXml(resDir); + String manifest = buildAppxManifest(request, arch, widgetDefinitions); + createFile(new File(layout, "AppxManifest.xml"), manifest.getBytes(StandardCharsets.UTF_8)); + + // Pack with makemsix (the MSIX SDK's cross-platform packer; runs on + // the Linux daemon hosts). Must be on PATH, CN1_MAKEMSIX overrides. + File msix = new File(resultDir, request.getMainClass() + ".msix"); + String makemsix = System.getenv("CN1_MAKEMSIX"); + if (makemsix == null || makemsix.isEmpty()) { + makemsix = "makemsix"; + } + if (!exec(getBuildDirectory(), 600000, makemsix, "pack", + "-d", layout.getAbsolutePath(), "-p", msix.getAbsolutePath()) || !msix.isFile()) { + throw new BuildException("MSIX packaging failed. Ensure the `makemsix` tool " + + "(from the msix-packaging project) is on PATH, or point CN1_MAKEMSIX at it."); + } + signMsixPackage(msix, request); + log("MSIX package: " + msix.getAbsolutePath()); + } catch (BuildException ex) { + throw ex; + } catch (Exception ex) { + throw new BuildException("Failed to build the MSIX package", ex); + } + } + + /** + * Derives the {@code } XML of the widget provider extension + * from the project's {@code surfaces.json} (the same build-time kinds + * manifest the iOS/Android builders consume). Returns null when the + * project has no surfaces.json / no kinds -- the MSIX is then produced + * without the widgets extension. + */ + private String buildWidgetDefinitionsXml(File resDir) throws Exception { + File surfacesJson = new File(resDir, "surfaces.json"); + if (!surfacesJson.isFile()) { + log("No surfaces.json in the project resources; the MSIX is packaged without " + + "Widgets Board definitions (widget kinds must be declared at build time)."); + return null; + } + java.util.Map doc; + try (java.io.InputStreamReader reader = new java.io.InputStreamReader( + new java.io.FileInputStream(surfacesJson), StandardCharsets.UTF_8)) { + doc = new com.codename1.builders.util.JSONParser().parseJSON(reader); + } + Object kindsObj = doc.get("kinds"); + if (!(kindsObj instanceof List) || ((List) kindsObj).isEmpty()) { + log("surfaces.json declares no widget kinds; the MSIX is packaged without " + + "Widgets Board definitions."); + return null; + } + // indented to sit directly inside in the manifest below + StringBuilder sb = new StringBuilder(); + for (Object kindObj : (List) kindsObj) { + if (!(kindObj instanceof java.util.Map)) { + continue; + } + java.util.Map kind = (java.util.Map) kindObj; + Object idObj = kind.get("id"); + if (!(idObj instanceof String) || !((String) idObj).matches("[a-z][a-z0-9_]*")) { + throw new BuildException("Invalid widget kind id '" + idObj + + "' in surfaces.json; ids must match [a-z][a-z0-9_]*"); + } + String id = (String) idObj; + String name = kind.get("name") instanceof String ? (String) kind.get("name") : id; + String description = kind.get("description") instanceof String + ? (String) kind.get("description") : name; + sb.append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n"); + } + return sb.toString(); + } + + /** + * Generates the AppxManifest.xml: package identity from the + * {@code windows.msix.*} hints, a full-trust win32 Application around + * app.exe, the COM ExeServer re-activating the exe with + * {@code -RegisterProcessAsComServer}, and (when surfaces.json declares + * kinds) the {@code com.microsoft.windows.widgets} app extension. + */ + private String buildAppxManifest(BuildRequest request, String arch, String widgetDefinitions) { + String identityName = request.getArg("windows.msix.identityName", request.getPackageName()); + String publisher = request.getArg("windows.msix.publisher", "CN=" + request.getDisplayName()); + String version = normalizeMsixVersion(request.getArg("windows.msix.version", + request.getVersion() != null ? request.getVersion() : "1.0.0.0")); + String displayName = escapeXml(request.getDisplayName()); + String procArch = ARCH_ARM64.equals(normalizeArch(arch)) ? "arm64" : "x64"; + + StringBuilder sb = new StringBuilder(); + sb.append("\n") + .append("\n") + .append(" \n") + .append(" \n") + .append(" ").append(displayName).append("\n") + .append(" ").append(escapeXml(request.getVendor() != null + ? request.getVendor() : request.getDisplayName())).append("\n") + .append(" Assets\\StoreLogo.png\n") + .append(" \n") + // 10.0.22000 == Windows 11: the Widgets Board floor. The package + // installs there and up; the plain exe remains the pre-11 channel. + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n"); + if (widgetDefinitions != null) { + sb.append(" \n") + // COM out-of-proc server: the Widgets Board activates the widget + // provider by re-launching this very exe with the + // -RegisterProcessAsComServer argument (handled in + // cn1_windows_widgetboard.cpp before the app UI would start). + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(widgetDefinitions) + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n"); + } + sb.append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append("\n"); + return sb.toString(); + } + + /** + * Signs the MSIX with osslsigncode (2.6+ supports the APPX/MSIX format), + * reusing the exe-signing command shape. Prefers windows.msix.pfx / + * windows.msix.password, falling back to the exe signing configuration; + * with no certificate at all the package is left unsigned -- installable + * only after the developer signs it, since Windows refuses unsigned MSIX. + */ + private void signMsixPackage(File msix, BuildRequest request) { + File pkcs12 = null; + String pfxHint = request.getArg("windows.msix.pfx", + request.getArg("windows.signing.pkcs12", null)); + try { + if (pfxHint != null && !pfxHint.isEmpty()) { + pkcs12 = new File(pfxHint); + if (!pkcs12.isFile()) { + throw new BuildException("windows.msix.pfx file not found: " + pkcs12.getAbsolutePath()); + } + } else if (request.getCertificate() != null && request.getCertificate().length > 0) { + pkcs12 = File.createTempFile("cn1-msixsign", ".p12", getBuildDirectory()); + try (FileOutputStream out = new FileOutputStream(pkcs12)) { + out.write(request.getCertificate()); + } + } + } catch (java.io.IOException ex) { + throw new BuildException("Failed to stage the MSIX signing certificate", ex); + } + if (pkcs12 == null) { + log("MSIX left unsigned (no certificate). Windows refuses to install unsigned MSIX " + + "packages: supply windows.msix.pfx/windows.msix.password (a cert the target " + + "machine trusts; self-signed works only for dev sideloading with the cert " + + "imported) or sign the package yourself before distribution."); + return; + } + String password = request.getArg("windows.msix.password", + request.getArg("windows.signing.password", request.getCertificatePassword())); + if (password == null) { + password = ""; + } + String tool = System.getenv("CN1_OSSLSIGNCODE"); + if (tool == null || tool.isEmpty()) { + tool = "osslsigncode"; + } + File signed = new File(msix.getParentFile(), msix.getName() + ".signed"); + List cmd = new ArrayList(); + cmd.add(tool); + cmd.add("sign"); + cmd.add("-pkcs12"); + cmd.add(pkcs12.getAbsolutePath()); + cmd.add("-pass"); + cmd.add(password); + cmd.add("-h"); + cmd.add(request.getArg("windows.signing.digest", "sha256")); + cmd.add("-in"); + cmd.add(msix.getAbsolutePath()); + cmd.add("-out"); + cmd.add(signed.getAbsolutePath()); + try { + if (!exec(getBuildDirectory(), 600000, cmd.toArray(new String[0])) || !signed.isFile()) { + throw new BuildException("MSIX signing failed (osslsigncode; version 2.6+ is needed " + + "for the MSIX format). Ensure osslsigncode is on PATH (CN1_OSSLSIGNCODE " + + "overrides) and the certificate/password are valid. Note: the certificate " + + "Subject must equal the manifest Publisher (windows.msix.publisher)."); + } + if (!msix.delete() || !signed.renameTo(msix)) { + copy(signed, msix); + signed.delete(); + } + log("Signed " + msix.getName()); + } catch (BuildException ex) { + throw ex; + } catch (Exception ex) { + throw new BuildException("MSIX signing failed (osslsigncode)", ex); + } + } + + /** Pads/truncates a version string to the four numeric parts MSIX requires. */ + static String normalizeMsixVersion(String version) { + String[] parts = (version == null ? "1.0.0.0" : version).split("\\."); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 4; i++) { + if (i > 0) { + sb.append('.'); + } + String part = i < parts.length ? parts[i].trim() : "0"; + boolean numeric = part.length() > 0; + for (int j = 0; j < part.length(); j++) { + if (!Character.isDigit(part.charAt(j))) { + numeric = false; + break; + } + } + sb.append(numeric ? part : "0"); + } + return sb.toString(); + } + + private static String escapeXml(String s) { + if (s == null) { + return ""; + } + return s.replace("&", "&").replace("<", "<").replace(">", ">") + .replace("\"", """).replace("'", "'"); + } + /** * Scans the app classes for native interfaces and generates the binding + * bootstrap stubs into a temp source tree, then compiles them into @@ -739,14 +1226,16 @@ private File resolveXwinSysroot(BuildRequest request) { * -fuse-ld=lld}) links against the SDK libs; {@code llvm-rc} gets the SDK include * the resource compiler would otherwise read from {@code %INCLUDE%} on Windows. */ - private void addCrossCompileConfigure(List configure, String triple, String arch, File sys) { + private void addCrossCompileConfigure(List configure, String triple, String arch, File sys, + String extraCompileFlags, String extraLinkFlags) { String a = sdkArchSubdir(arch); String inc = "--target=" + triple + " /imsvc " + new File(sys, "crt/include") + " /imsvc " + new File(sys, "sdk/include/ucrt") + " /imsvc " + new File(sys, "sdk/include/um") + " /imsvc " + new File(sys, "sdk/include/shared") - + " /imsvc " + new File(sys, "sdk/include/winrt"); + + " /imsvc " + new File(sys, "sdk/include/winrt") + + extraCompileFlags; String rcFlags = "-I " + new File(sys, "sdk/include/um") + " -I " + new File(sys, "sdk/include/shared") + " -I " + new File(sys, "crt/include") @@ -754,7 +1243,8 @@ private void addCrossCompileConfigure(List configure, String triple, Str String linkFlags = "-fuse-ld=lld" + " /libpath:" + new File(sys, "crt/lib/" + a) + " /libpath:" + new File(sys, "sdk/lib/um/" + a) - + " /libpath:" + new File(sys, "sdk/lib/ucrt/" + a); + + " /libpath:" + new File(sys, "sdk/lib/ucrt/" + a) + + extraLinkFlags; configure.add("-DCMAKE_SYSTEM_NAME=Windows"); configure.add("-DCMAKE_SYSTEM_PROCESSOR=" + cmakeSystemProcessor(arch)); // STATIC_LIBRARY so CMake's compiler-detection try_compile does not need a 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 82c9bca4e71..42fc9213257 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 @@ -649,6 +649,47 @@ private void createAntProject() throws IOException, LibraryPropertiesException, cn1SettingsProps.setProperty("codename1.arg.hyp.beamId", logPasskey); cn1SettingsProps.setProperty("codename1.arg.maven.codenameone-core.version", cn1MavenVersion); cn1SettingsProps.setProperty("codename1.arg.maven.codenameone-maven-plugin", cn1MavenPluginVersion); + + // 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 + // file into, so base64-encode its bytes into the ios.appext..provisioningData + // build arg; the daemon decodes it back to .mobileprovision before signing. + // Done generically over , mirroring the daemon's per-extension plumbing. + String appExtPrefix = "codename1.ios.appext."; + String appExtSuffix = ".provision"; + for (String settingKey : new ArrayList(cn1SettingsProps.stringPropertyNames())) { + if (!settingKey.startsWith(appExtPrefix) || !settingKey.endsWith(appExtSuffix)) { + continue; + } + String extName = settingKey.substring(appExtPrefix.length(), settingKey.length() - appExtSuffix.length()); + if (extName.isEmpty()) { + continue; + } + String dataKey = "codename1.arg.ios.appext." + extName + ".provisioningData"; + if (cn1SettingsProps.containsKey(dataKey)) { + continue; + } + String profilePath = cn1SettingsProps.getProperty(settingKey); + if (profilePath == null || profilePath.trim().isEmpty()) { + continue; + } + File profileFile = new File(profilePath.trim()); + if (!profileFile.exists() || !profileFile.isFile()) { + getLog().warn("The app extension provisioning profile referenced by " + settingKey + + " was not found at " + profileFile.getAbsolutePath() + ". Skipping it; the " + + extName + " extension will not receive a provisioning profile for this build."); + continue; + } + try { + byte[] profileBytes = FileUtils.readFileToByteArray(profileFile); + cn1SettingsProps.setProperty(dataKey, java.util.Base64.getEncoder().encodeToString(profileBytes)); + } catch (IOException ex) { + getLog().warn("Failed to read the app extension provisioning profile referenced by " + settingKey + + " at " + profileFile.getAbsolutePath() + ". Skipping it: " + ex.getMessage()); + } + } + try (FileOutputStream fos = new FileOutputStream(codenameOneSettingsCopy)) { cn1SettingsProps.store(fos,""); 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 new file mode 100644 index 00000000000..436f098d78c --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWidgetExtensionBuilder.java @@ -0,0 +1,494 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Generates the {@code CN1Widgets} WidgetKit app-extension target that the Codename One + * iOS build wires into the generated Xcode project when the app references + * {@code com.codename1.surfaces} (see the {@code surfaces.json} project manifest and the + * {@code ios.surfaces.*} build hints). + * + *

The extension is fully generic: the static Swift renderer sources shipped as plugin + * resources under {@code com/codename1/builders/surfaces/ios/} render whatever timeline + * documents the app published into the shared App Group container, so the only + * per-project code generated here is:

+ * + *
    + *
  • {@code CN1SurfaceConfig.swift} - the app group id constant the provider and + * renderer use to resolve the shared container;
  • + *
  • {@code CN1WidgetBundle.swift} - the {@code @main WidgetBundle} plus one tiny + * concrete {@code CN1Widget_} struct per widget kind. The Widget protocol + * requires {@code init()}, so a parameterized struct cannot serve every kind; each + * generated struct hardcodes its kind metadata and delegates to the shared + * {@code cn1MakeWidgetConfiguration} factory in CN1DescriptorWidget.swift. When + * live activities are enabled the bundle also lists {@code CN1LiveActivityWidget()} + * unconditionally - the struct itself guards every ActivityKit reference with + * {@code #if canImport(ActivityKit)}, which keeps the composition simple and + * compiles cleanly on SDKs/platforms without ActivityKit.
  • + *
+ * + *

{@link #buildAppTargetFileMap()} returns the glue compiled into the MAIN APP target + * (the Swift {@code CN1SurfaceBridge} the Objective-C natives reach via + * {@code NSClassFromString}, plus copies of the attributes/config files - ActivityKit + * matches app and extension by the {@code ActivityAttributes} type, so both modules need + * the identical struct).

+ * + *

The extension's deployment target defaults to 16.1 (ActivityKit's floor); the host + * app's own deployment target is unaffected - the extension simply never runs on older + * iOS versions.

+ */ +public class IOSWidgetExtensionBuilder { + + /** Info.plist key holding the App Group id, read by extension and app alike. */ + public static final String APP_GROUP_PLIST_KEY = "CN1SurfacesAppGroup"; + + /** Classpath folder holding the static Swift renderer sources. */ + private static final String RESOURCE_ROOT = "/com/codename1/builders/surfaces/ios/"; + + /** Static Swift sources copied verbatim into the extension target. */ + private static final String[] EXTENSION_SOURCES = { + "CN1SurfaceModel.swift", + "CN1SurfaceRenderer.swift", + "CN1WidgetProvider.swift", + "CN1DescriptorWidget.swift", + "CN1SurfaceAttributes.swift", + }; + + /** + * One widget kind declared in surfaces.json. Ids must match + * {@code [a-z][a-z0-9_]*} - they become Swift struct names and WidgetKit kind ids. + */ + public static class Kind { + private final String id; + private String name; + private String description; + private List iosFamilies = new ArrayList(); + private String previewName; + + public Kind(String id) { + this.id = id; + } + + public Kind setName(String name) { + this.name = name; + return this; + } + + public Kind setDescription(String description) { + this.description = description; + return this; + } + + /** Families from {@code small}, {@code medium}, {@code large}, {@code lockscreen}. */ + public Kind setIosFamilies(List families) { + this.iosFamilies = families == null ? new ArrayList() : families; + return this; + } + + public Kind setPreviewName(String previewName) { + this.previewName = previewName; + return this; + } + + public String getId() { return id; } + public String getName() { return name == null || name.length() == 0 ? id : name; } + public String getDescription() { return description == null ? "" : description; } + public List getIosFamilies() { return iosFamilies; } + public String getPreviewName() { return previewName; } + } + + private String extensionName = "CN1Widgets"; + private String hostBundleId; + private String appGroupId; + private String deploymentTarget = "16.1"; + private boolean liveActivitiesEnabled; + private final List kinds = new ArrayList(); + + /** Bare-bones constructor. Configure with the fluent setters. */ + public IOSWidgetExtensionBuilder() {} + + /** + * Sets the extension target name (Xcode target, .appex bundle and bundle-id suffix). + * Must be an ASCII identifier. Defaults to {@code CN1Widgets}. + */ + public IOSWidgetExtensionBuilder setExtensionName(String name) { + this.extensionName = name; + return this; + } + + /** The host iOS app's bundle identifier. Required. */ + public IOSWidgetExtensionBuilder setHostBundleId(String id) { + this.hostBundleId = id; + return this; + } + + /** + * The App Group identifier shared between the host app and the extension. Apple + * requires it to start with {@code group.}. Required. + */ + public IOSWidgetExtensionBuilder setAppGroupId(String id) { + this.appGroupId = id; + return this; + } + + /** + * iOS deployment target of the extension target only (the host app's floor is + * unchanged). Defaults to {@code 16.1}, ActivityKit's minimum. + */ + public IOSWidgetExtensionBuilder setDeploymentTarget(String target) { + this.deploymentTarget = target; + return this; + } + + /** Adds the live activity widget to the generated bundle. */ + public IOSWidgetExtensionBuilder setLiveActivitiesEnabled(boolean enabled) { + this.liveActivitiesEnabled = enabled; + return this; + } + + /** Declares one widget kind (from surfaces.json). */ + public IOSWidgetExtensionBuilder addKind(Kind kind) { + kinds.add(kind); + return this; + } + + public String getExtensionName() { return extensionName; } + public String getHostBundleId() { return hostBundleId; } + public String getAppGroupId() { return appGroupId; } + public String getDeploymentTarget() { return deploymentTarget; } + public boolean isLiveActivitiesEnabled() { return liveActivitiesEnabled; } + public List getKinds() { return kinds; } + + /** + * Builds the in-memory file map of the extension target, keyed by relative path + * inside the extension folder. + */ + public Map buildFileMap() throws IOException { + validate(); + LinkedHashMap map = new LinkedHashMap(); + map.put("Info.plist", utf8(buildInfoPlist())); + map.put(extensionName + ".entitlements", utf8(buildEntitlements())); + map.put("buildSettings.properties", utf8(buildBuildSettings())); + for (String source : EXTENSION_SOURCES) { + map.put(source, utf8(loadResource(source))); + } + if (liveActivitiesEnabled) { + map.put("CN1LiveActivityWidget.swift", utf8(loadResource("CN1LiveActivityWidget.swift"))); + } + map.put("CN1SurfaceConfig.swift", utf8(buildConfigSwift())); + map.put("CN1WidgetBundle.swift", utf8(buildBundleSwift())); + return map; + } + + /** + * Builds the glue compiled into the MAIN APP target: the {@code CN1SurfaceBridge} + * Objective-C-visible Swift class plus copies of the attributes/config files. The + * caller writes these into the {@code -src} folder, which the generated + * Xcode schemes script sweeps into the app target's compile sources. + */ + public Map buildAppTargetFileMap() throws IOException { + validate(); + LinkedHashMap map = new LinkedHashMap(); + map.put("CN1SurfaceBridge.swift", utf8(loadResource("CN1SurfaceBridge.swift"))); + map.put("CN1SurfaceAttributes.swift", utf8(loadResource("CN1SurfaceAttributes.swift"))); + map.put("CN1SurfaceConfig.swift", utf8(buildConfigSwift())); + return map; + } + + private void validate() { + if (extensionName == null || !isIdentifier(extensionName)) { + throw new IllegalStateException( + "extension name must be ASCII letters/digits/_/- only: " + extensionName); + } + if (hostBundleId == null || hostBundleId.length() == 0) { + throw new IllegalStateException("hostBundleId must be set"); + } + if (appGroupId == null || !appGroupId.startsWith("group.")) { + throw new IllegalStateException("appGroupId must start with 'group.' (Apple " + + "requirement; from surfaces.json or the ios.surfaces.appGroup build hint): " + + appGroupId); + } + if (kinds.isEmpty() && !liveActivitiesEnabled) { + throw new IllegalStateException("surfaces.json declares neither widget kinds nor " + + "liveActivities: there is nothing to generate"); + } + // WidgetBundleBuilder composes at most 10 widgets per bundle body; keeping the + // generator single-bundle is simpler and 9 kinds is far beyond practical use. + if (kinds.size() > (liveActivitiesEnabled ? 9 : 10)) { + throw new IllegalStateException("surfaces.json declares more than " + + (liveActivitiesEnabled ? 9 : 10) + " widget kinds; a single WidgetBundle " + + "supports at most 10 widgets"); + } + for (Kind kind : kinds) { + if (kind.getId() == null || !isKindId(kind.getId())) { + throw new IllegalStateException("widget kind ids must match [a-z][a-z0-9_]*: " + + kind.getId()); + } + } + } + + private static boolean isIdentifier(String s) { + if (s.length() == 0) return false; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '_' || c == '-'; + if (!ok) return false; + } + return true; + } + + private static boolean isKindId(String s) { + if (s.length() == 0) return false; + char first = s.charAt(0); + if (first < 'a' || first > 'z') return false; + for (int i = 1; i < s.length(); i++) { + char c = s.charAt(i); + boolean ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; + if (!ok) return false; + } + return true; + } + + private static byte[] utf8(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + private static String loadResource(String name) throws IOException { + InputStream in = IOSWidgetExtensionBuilder.class.getResourceAsStream(RESOURCE_ROOT + name); + if (in == null) { + throw new IOException("Missing plugin resource " + RESOURCE_ROOT + name); + } + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int len; + while ((len = in.read(buffer)) > 0) { + out.write(buffer, 0, len); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } finally { + in.close(); + } + } + + private String buildInfoPlist() { + StringBuilder sb = new StringBuilder(1024); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + plistKeyString(sb, "CFBundleDevelopmentRegion", "en"); + plistKeyString(sb, "CFBundleDisplayName", extensionName); + plistKeyString(sb, "CFBundleExecutable", "$(EXECUTABLE_NAME)"); + plistKeyString(sb, "CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)"); + plistKeyString(sb, "CFBundleInfoDictionaryVersion", "6.0"); + plistKeyString(sb, "CFBundleName", "$(PRODUCT_NAME)"); + plistKeyString(sb, "CFBundlePackageType", "$(PRODUCT_BUNDLE_PACKAGE_TYPE)"); + plistKeyString(sb, "CFBundleShortVersionString", "1.0"); + plistKeyString(sb, "CFBundleVersion", "1"); + plistKeyString(sb, APP_GROUP_PLIST_KEY, appGroupId); + // No NSExtensionPrincipalClass: the @main CN1WidgetBundle is the entry point. + // (NSSupportsLiveActivities belongs in the HOST APP's Info.plist, injected by + // IPhoneBuilder, not here.) + sb.append(" NSExtension\n"); + sb.append(" \n"); + sb.append(" NSExtensionPointIdentifier\n"); + sb.append(" com.apple.widgetkit-extension\n"); + sb.append(" \n"); + sb.append("\n"); + sb.append("\n"); + return sb.toString(); + } + + private String buildEntitlements() { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + sb.append(" com.apple.security.application-groups\n"); + sb.append(" \n"); + sb.append(" ").append(escapeXml(appGroupId)).append("\n"); + sb.append(" \n"); + sb.append("\n"); + sb.append("\n"); + return sb.toString(); + } + + private String buildBuildSettings() { + // These properties override the defaults synthesized by IPhoneBuilder when + // wiring the extension target into Xcode (mirrors IOSShareExtensionBuilder). + StringBuilder sb = new StringBuilder(); + sb.append("# Auto-generated by Codename One IOSWidgetExtensionBuilder.\n"); + sb.append("# Picked up by com.codename1.builders.IPhoneBuilder when the CN1Widgets\n"); + sb.append("# extension folder is wired into the generated Xcode project.\n"); + sb.append("IPHONEOS_DEPLOYMENT_TARGET=").append(deploymentTarget).append("\n"); + sb.append("SWIFT_VERSION=5.0\n"); + sb.append("ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES=YES\n"); + sb.append("SKIP_INSTALL=YES\n"); + sb.append("PRODUCT_BUNDLE_IDENTIFIER=").append(hostBundleId).append(".") + .append(extensionName).append("\n"); + sb.append("CODE_SIGN_ENTITLEMENTS=").append(extensionName).append("/") + .append(extensionName).append(".entitlements\n"); + sb.append("INFOPLIST_FILE=").append(extensionName).append("/Info.plist\n"); + return sb.toString(); + } + + private String buildConfigSwift() { + StringBuilder sb = new StringBuilder(); + sb.append("// Auto-generated by Codename One from surfaces.json / ios.surfaces.appGroup.\n"); + sb.append("// The provider, renderer and bridge resolve the shared App Group container\n"); + sb.append("// through this constant. Compiled into both the app and extension targets.\n"); + sb.append("import Foundation\n"); + sb.append("\n"); + sb.append("let cn1SurfacesAppGroup = \"").append(escapeSwift(appGroupId)).append("\"\n"); + return sb.toString(); + } + + private String buildBundleSwift() { + StringBuilder sb = new StringBuilder(2048); + sb.append("// Auto-generated by Codename One from surfaces.json. The @main entry point of\n"); + sb.append("// the CN1Widgets extension: one concrete widget struct per declared kind (the\n"); + sb.append("// Widget protocol requires init(), so kinds cannot share a parameterized\n"); + sb.append("// struct) plus the live activity widget when enabled.\n"); + sb.append("import SwiftUI\n"); + sb.append("import WidgetKit\n"); + sb.append("\n"); + sb.append("@main\n"); + sb.append("struct CN1WidgetBundle: WidgetBundle {\n"); + sb.append(" var body: some Widget {\n"); + for (Kind kind : kinds) { + sb.append(" ").append(structName(kind)).append("()\n"); + } + if (liveActivitiesEnabled) { + sb.append(" CN1LiveActivityWidget()\n"); + } + sb.append(" }\n"); + sb.append("}\n"); + for (Kind kind : kinds) { + sb.append("\n"); + sb.append("struct ").append(structName(kind)).append(": Widget {\n"); + sb.append(" var body: some WidgetConfiguration {\n"); + sb.append(" cn1MakeWidgetConfiguration(\n"); + 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"); + sb.append(" }\n"); + sb.append("}\n"); + } + return sb.toString(); + } + + private static String structName(Kind kind) { + return "CN1Widget_" + kind.getId(); + } + + private static String familiesSwift(Kind kind) { + List families = kind.getIosFamilies(); + StringBuilder sb = new StringBuilder(); + if (families != null) { + for (String family : families) { + String mapped = mapFamily(family); + if (mapped != null && sb.indexOf(mapped) < 0) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(mapped); + } + } + } + if (sb.length() == 0) { + // No (usable) family declaration: all three home-screen sizes. + return ".systemSmall, .systemMedium, .systemLarge"; + } + return sb.toString(); + } + + private static String mapFamily(String family) { + // 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. + if ("small".equals(family) || "systemSmall".equals(family)) { + return ".systemSmall"; + } + if ("medium".equals(family) || "systemMedium".equals(family)) { + return ".systemMedium"; + } + if ("large".equals(family) || "systemLarge".equals(family)) { + return ".systemLarge"; + } + if ("lockscreen".equals(family) || "accessoryRectangular".equals(family)) { + return ".accessoryRectangular"; + } + // Unknown family names are skipped so newer manifests degrade gracefully. + return null; + } + + private static void plistKeyString(StringBuilder sb, String key, String value) { + sb.append(" ").append(escapeXml(key)).append("\n"); + sb.append(" ").append(escapeXml(value)).append("\n"); + } + + private static String escapeXml(String s) { + StringBuilder out = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '&': out.append("&"); break; + case '<': out.append("<"); break; + case '>': out.append(">"); break; + case '"': out.append("""); break; + case '\'': out.append("'"); break; + default: out.append(c); + } + } + return out.toString(); + } + + private static String escapeSwift(String s) { + StringBuilder out = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '\\': out.append("\\\\"); break; + case '"': out.append("\\\""); break; + case '\n': out.append("\\n"); break; + case '\r': out.append("\\r"); break; + case '\t': out.append("\\t"); break; + default: out.append(c); + } + } + return out.toString(); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/drawable/cn1_surface_rounded.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/drawable/cn1_surface_rounded.xml new file mode 100644 index 00000000000..08f2bc06c0f --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/drawable/cn1_surface_rounded.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_box.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_box.xml new file mode 100644 index 00000000000..b8821af23f8 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_box.xml @@ -0,0 +1,7 @@ + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_h.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_h.xml new file mode 100644 index 00000000000..53452673d6a --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_h.xml @@ -0,0 +1,7 @@ + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_v.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_v.xml new file mode 100644 index 00000000000..84a6fa7d58e --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_v.xml @@ -0,0 +1,6 @@ + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_weight1_h.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_weight1_h.xml new file mode 100644 index 00000000000..8fa8a0dad0e --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_weight1_h.xml @@ -0,0 +1,9 @@ + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_weight1_v.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_weight1_v.xml new file mode 100644 index 00000000000..2635ebb7233 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_weight1_v.xml @@ -0,0 +1,8 @@ + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_chronometer.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_chronometer.xml new file mode 100644 index 00000000000..fcb9d0a9add --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_chronometer.xml @@ -0,0 +1,10 @@ + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_column.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_column.xml new file mode 100644 index 00000000000..e8f7e290c24 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_column.xml @@ -0,0 +1,8 @@ + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image.xml new file mode 100644 index 00000000000..a238d94db96 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image.xml @@ -0,0 +1,7 @@ + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image_center.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image_center.xml new file mode 100644 index 00000000000..6c352d636f3 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image_center.xml @@ -0,0 +1,7 @@ + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image_fill.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image_fill.xml new file mode 100644 index 00000000000..6b3313df7df --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image_fill.xml @@ -0,0 +1,7 @@ + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_progress.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_progress.xml new file mode 100644 index 00000000000..e6fb45f8758 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_progress.xml @@ -0,0 +1,10 @@ + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_progress_circular.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_progress_circular.xml new file mode 100644 index 00000000000..f139729249e --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_progress_circular.xml @@ -0,0 +1,12 @@ + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_row.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_row.xml new file mode 100644 index 00000000000..f0f7a26a1da --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_row.xml @@ -0,0 +1,9 @@ + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_spacer.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_spacer.xml new file mode 100644 index 00000000000..8ac24047fe8 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_spacer.xml @@ -0,0 +1,7 @@ + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_text.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_text.xml new file mode 100644 index 00000000000..ff2ea6e3d3c --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_text.xml @@ -0,0 +1,9 @@ + + + diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_textclock.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_textclock.xml new file mode 100644 index 00000000000..8c2ecac6cd4 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_textclock.xml @@ -0,0 +1,9 @@ + + + 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 new file mode 100644 index 00000000000..72a4c257e00 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1DescriptorWidget.swift @@ -0,0 +1,99 @@ +// 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. +// +// Note on the composition strategy: the Widget protocol requires init(), so one +// parameterized struct cannot serve every kind. The generated CN1WidgetBundle.swift +// therefore emits a tiny concrete struct per kind (CN1Widget_) whose body +// delegates to cn1MakeWidgetConfiguration below with its hardcoded kind metadata. + +import Foundation +import SwiftUI +import WidgetKit + +/// Renders the layout matching the active widget family, resolving to the "default" +/// layout when no per-family override was published. +struct CN1WidgetEntryView: View { + let entry: CN1WidgetEntry + @Environment(\.widgetFamily) var family + + var body: some View { + let layout = cn1LayoutForFamily(entry.layouts, family: family) + return Group { + if let layout = layout { + cn1RenderNode(layout, CN1RenderContext( + state: entry.state, + imagesDir: entry.imagesDir, + source: entry.kind, + allowLinks: cn1FamilyAllowsLinks(family), + parentAxis: nil)) + } else { + // Placeholder shown before the app publishes its first timeline. + Text("No data yet") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + // The root action fires on families where per-node Links are unsupported + // (small widgets, lock screen accessories) and as the default tap elsewhere. + .widgetURL(cn1RootActionURL(layout, source: entry.kind)) + .cn1WidgetContainerBackground() + } +} + +func cn1LayoutForFamily(_ layouts: [String: Any], family: WidgetFamily) -> [String: Any]? { + let key: String + switch family { + case .systemSmall: + key = "small" + case .systemMedium: + key = "medium" + case .systemLarge, .systemExtraLarge: + key = "large" + case .accessoryRectangular: + key = "lockscreen" + default: + key = "default" + } + if let layout = layouts[key] as? [String: Any] { + return layout + } + return layouts["default"] as? [String: Any] +} + +/// Per-node Link actions only work on medium and larger home-screen families; everywhere +/// else the tap target is the whole widget via widgetURL. +func cn1FamilyAllowsLinks(_ family: WidgetFamily) -> Bool { + switch family { + case .systemMedium, .systemLarge, .systemExtraLarge: + return true + default: + return false + } +} + +extension View { + /// iOS 17 requires widgets to declare a container background (or show a scolding + /// placeholder). The surfaces node tree draws its own backgrounds, so declare a + /// clear one; earlier versions pass through unchanged. + @ViewBuilder func cn1WidgetContainerBackground() -> some View { + if #available(iOS 17.0, *) { + self.containerBackground(for: .widget) { + Color.clear + } + } else { + self + } + } +} + +/// Shared factory behind every generated CN1Widget_ struct. +func cn1MakeWidgetConfiguration(kind: String, displayName: String, description desc: String, + families: [WidgetFamily]) -> some WidgetConfiguration { + return StaticConfiguration(kind: kind, provider: CN1WidgetProvider(kind: kind)) { entry in + CN1WidgetEntryView(entry: entry) + } + .configurationDisplayName(displayName) + .description(desc) + .supportedFamilies(families) +} diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1LiveActivityWidget.swift b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1LiveActivityWidget.swift new file mode 100644 index 00000000000..f9f10cafd4b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1LiveActivityWidget.swift @@ -0,0 +1,93 @@ +// Auto-generated by Codename One from the com.codename1.surfaces framework. +// Compiled ONLY into the CN1Widgets extension target (iOS 16.1+), and only when +// surfaces.json enables live activities. Renders the lock screen / banner presentation +// and every Dynamic Island region from the descriptor persisted in the activity +// attributes, re-interpolating ${key} placeholders from the current content state. + +import Foundation +import SwiftUI +import WidgetKit + +#if canImport(ActivityKit) && !targetEnvironment(macCatalyst) +import ActivityKit + +struct CN1LiveActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: CN1SurfaceAttributes.self) { context in + CN1LiveActivityLockScreenView(context: context) + } dynamicIsland: { context in + let doc = cn1ParseJSONObject(context.attributes.descriptorJson) + let island = cn1Dict(doc, "island") ?? [:] + let state = cn1ParseJSONObject(context.state.stateJson) + let source = context.attributes.activityType + return DynamicIsland { + // Expanded regions support per-node Links; missing regions collapse. + DynamicIslandExpandedRegion(.leading) { + cn1IslandRegion(island, "expandedLeading", state: state, source: source, allowLinks: true) + } + DynamicIslandExpandedRegion(.trailing) { + cn1IslandRegion(island, "expandedTrailing", state: state, source: source, allowLinks: true) + } + DynamicIslandExpandedRegion(.center) { + cn1IslandRegion(island, "expandedCenter", state: state, source: source, allowLinks: true) + } + DynamicIslandExpandedRegion(.bottom) { + cn1IslandRegion(island, "expandedBottom", state: state, source: source, allowLinks: true) + } + } compactLeading: { + cn1IslandRegion(island, "compactLeading", state: state, source: source, allowLinks: false) + } compactTrailing: { + cn1IslandRegion(island, "compactTrailing", state: state, source: source, allowLinks: false) + } minimal: { + cn1IslandRegion(island, "minimal", state: state, source: source, allowLinks: false) + } + .widgetURL(cn1RootActionURL(cn1Dict(doc, "content"), source: source)) + .keylineTint(cn1Color(doc["tint"])) + } + } +} + +/// Renders one Dynamic Island region's node tree; a missing region renders empty (the +/// island collapses it to nothing, its sensible default). +private func cn1IslandRegion(_ island: [String: Any], _ key: String, state: [String: Any], + source: String, allowLinks: Bool) -> AnyView { + guard let node = cn1Dict(island, key) else { + return AnyView(EmptyView()) + } + return cn1RenderNode(node, CN1RenderContext( + state: state, + imagesDir: cn1SurfacesActivitiesDir(), + source: source, + allowLinks: allowLinks, + parentAxis: nil)) +} + +/// The lock screen / banner presentation: the descriptor's content root rendered with +/// the current state. Availability-annotated so the file compiles at any deployment +/// target the generated project ends up with (ActivityKit types are iOS 16.1+). +@available(iOS 16.1, *) +struct CN1LiveActivityLockScreenView: View { + let context: ActivityViewContext + + var body: some View { + let doc = cn1ParseJSONObject(context.attributes.descriptorJson) + let state = cn1ParseJSONObject(context.state.stateJson) + let source = context.attributes.activityType + let content = cn1Dict(doc, "content") + return Group { + if let content = content { + cn1RenderNode(content, CN1RenderContext( + state: state, + imagesDir: cn1SurfacesActivitiesDir(), + source: source, + allowLinks: true, + parentAxis: nil)) + } else { + EmptyView() + } + } + .widgetURL(cn1RootActionURL(content, source: source)) + .activityBackgroundTint(cn1Color(doc["tint"])) + } +} +#endif diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceAttributes.swift b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceAttributes.swift new file mode 100644 index 00000000000..8a10fa8b048 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceAttributes.swift @@ -0,0 +1,38 @@ +// Auto-generated by Codename One from the com.codename1.surfaces framework. +// Compiled into BOTH the app target and the CN1Widgets extension target: ActivityKit +// matches a live activity between the requesting app and the rendering extension by the +// ActivityAttributes type, so both modules carry an identical copy of this struct. +// +// The app target may deploy far below iOS 16.1, hence the canImport + @available guards. +// +// The descriptor JSON (layout template, island regions, tint) is immutable for the +// lifetime of the activity and rides in the attributes; every update ships only the flat +// state JSON in the content state, matching ActivityKit's attributes/state split. + +import Foundation + +#if canImport(ActivityKit) && !targetEnvironment(macCatalyst) +import ActivityKit + +@available(iOS 16.1, *) +public struct CN1SurfaceAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + /// Flat state map (JSON) resolving the descriptor's ${key} placeholders. + public var stateJson: String + + public init(stateJson: String) { + self.stateJson = stateJson + } + } + + /// The full serialized live activity descriptor (schema v1). + public var descriptorJson: String + /// The descriptor's activity type, echoed as the src of action deep links. + public var activityType: String + + public init(descriptorJson: String, activityType: String) { + self.descriptorJson = descriptorJson + self.activityType = activityType + } +} +#endif diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceBridge.swift b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceBridge.swift new file mode 100644 index 00000000000..46977ef5bcc --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceBridge.swift @@ -0,0 +1,189 @@ +// Auto-generated by Codename One from the com.codename1.surfaces framework. +// Compiled into the MAIN APP target (copied into -src by the build). +// WidgetKit and ActivityKit are Swift-only frameworks, so the Objective-C surfaces +// natives in IOSNative.m reach this class via NSClassFromString(@"CN1SurfaceBridge") +// and typed objc_msgSend casts -- keep the @objc names below stable. +// +// The app target may deploy far below iOS 16.1: every WidgetKit/ActivityKit reference +// is behind canImport + runtime #available guards so the class compiles and loads on +// any deployment target and answers "unsupported" gracefully. + +import Foundation + +#if canImport(WidgetKit) +import WidgetKit +#endif +#if canImport(ActivityKit) && !targetEnvironment(macCatalyst) +import ActivityKit +#endif + +@objc(CN1SurfaceBridge) +public class CN1SurfaceBridge: NSObject { + + // WidgetCenter.getCurrentConfigurations is async; cache the last answer so the + // synchronous Java query returns something useful (0 while still unknown, the + // documented contract). Each call refreshes the cache for the next one. + private static let countsLock = NSLock() + private static var cachedCounts: [String: Int] = [:] + + /// Re-renders the widgets of one kind from their persisted timelines; an empty kind + /// reloads every kind. + @objc public static func reloadTimelines(_ kind: String) { + #if canImport(WidgetKit) + if #available(iOS 14.0, *) { + if kind.isEmpty { + WidgetCenter.shared.reloadAllTimelines() + } else { + WidgetCenter.shared.reloadTimelines(ofKind: kind) + } + } + #endif + } + + /// Number of installed widget instances of a kind; 0 while the async WidgetCenter + /// answer has not arrived yet. + @objc public static func installedCount(_ kind: String) -> Int { + #if canImport(WidgetKit) + if #available(iOS 14.0, *) { + WidgetCenter.shared.getCurrentConfigurations { result in + if case .success(let infos) = result { + var counts: [String: Int] = [:] + for info in infos { + counts[info.kind, default: 0] += 1 + } + countsLock.lock() + cachedCounts = counts + countsLock.unlock() + } + } + countsLock.lock() + let count = cachedCounts[kind] ?? 0 + countsLock.unlock() + return count + } + #endif + return 0 + } + + /// True when ActivityKit is available and the user has not disabled live activities. + @objc public static func activitiesEnabled() -> Bool { + #if canImport(ActivityKit) && !targetEnvironment(macCatalyst) + if #available(iOS 16.1, *) { + return ActivityAuthorizationInfo().areActivitiesEnabled + } + #endif + return false + } + + /// Starts a live activity from the serialized descriptor (which embeds the initial + /// state). Returns the ActivityKit activity id, or "" on failure. + @objc public static func startActivity(_ descriptorJson: String) -> String { + #if canImport(ActivityKit) && !targetEnvironment(macCatalyst) + if #available(iOS 16.1, *) { + guard let data = descriptorJson.data(using: .utf8), + let doc = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else { + return "" + } + let activityType = doc["type"] as? String ?? "" + var stateJson = "{}" + if let state = doc["state"], JSONSerialization.isValidJSONObject(state), + let stateData = try? JSONSerialization.data(withJSONObject: state, options: []), + let encoded = String(data: stateData, encoding: .utf8) { + stateJson = encoded + } + let attributes = CN1SurfaceAttributes(descriptorJson: descriptorJson, activityType: activityType) + let contentState = CN1SurfaceAttributes.ContentState(stateJson: stateJson) + do { + // The request(attributes:contentState:pushType:) signature is the iOS 16.1 + // baseline (deprecated in 16.2 in favor of ActivityContent); intentional so + // the extension's 16.1 deployment target keeps working. + let activity = try Activity.request( + attributes: attributes, contentState: contentState, pushType: nil) + // Reclaim any image blobs left by a prior activity that has fully ended. + cn1SweepActivityImages() + return activity.id + } catch { + NSLog("CN1SurfaceBridge: failed to start live activity: %@", String(describing: error)) + // The Java bridge wrote this start's images before calling us; the request + // failed so no activity references them -- reclaim them now. + cn1SweepActivityImages() + return "" + } + } + #endif + return "" + } + + /// Pushes a fresh state into a running activity; the views re-interpolate locally. + @objc public static func updateActivity(_ id: String, stateJson: String) { + #if canImport(ActivityKit) && !targetEnvironment(macCatalyst) + if #available(iOS 16.1, *) { + let contentState = CN1SurfaceAttributes.ContentState(stateJson: stateJson) + Task { + for activity in Activity.activities where activity.id == id { + await activity.update(using: contentState) + } + } + } + #endif + } + + /// Ends a running activity, optionally showing a final state before dismissal. + @objc public static func endActivity(_ id: String, finalStateJson: String?, immediate: Bool) { + #if canImport(ActivityKit) && !targetEnvironment(macCatalyst) + if #available(iOS 16.1, *) { + let finalState = finalStateJson.map { CN1SurfaceAttributes.ContentState(stateJson: $0) } + Task { + for activity in Activity.activities where activity.id == id { + await activity.end(using: finalState, dismissalPolicy: immediate ? .immediate : .default) + } + // Reclaim image blobs after the end resolves: a non-immediate dismissal leaves the + // activity lingering in Activity.activities (and its artwork referenced), so only a + // truly-gone activity's images are swept. + cn1SweepActivityImages() + } + } + #endif + } + + /// Deletes live-activity image blobs no longer referenced by ANY tracked activity. + /// Activity.activities is authoritative for what is still live OR lingering after end() (a + /// dismissed activity remains there until the system removes it), and every activity's + /// descriptorJson carries the complete `images` reference list, so a content-hash blob + /// survives while any activity still references it and is reclaimed once none do -- fixing the + /// unbounded growth of the shared cn1surfaces/activities directory. + @available(iOS 16.1, *) + static func cn1SweepActivityImages() { + // Resolve the activities image dir inline from the app-group constant: this bridge is + // compiled into the APP target, which does not include CN1SurfaceModel.swift (its + // cn1SurfacesActivitiesDir lives only in the widget extension), so mirror the same path. + guard let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: cn1SurfacesAppGroup) else { + return + } + let dir = container.appendingPathComponent("cn1surfaces", isDirectory: true) + .appendingPathComponent("activities", isDirectory: true) + var referenced = Set() + for activity in Activity.activities { + guard let data = activity.attributes.descriptorJson.data(using: .utf8), + let doc = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let names = doc["images"] as? [Any] else { + continue + } + for name in names { + if let s = name as? String { + referenced.insert(s) + } + } + } + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { + return + } + for url in entries where url.pathExtension == "png" { + if !referenced.contains(url.deletingPathExtension().lastPathComponent) { + try? fm.removeItem(at: url) + } + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceModel.swift b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceModel.swift new file mode 100644 index 00000000000..8d7f91542a5 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceModel.swift @@ -0,0 +1,327 @@ +// Auto-generated by Codename One from the com.codename1.surfaces framework. +// Compiled ONLY into the CN1Widgets extension target (iOS 16.1+), so no availability +// guards are needed here. Tolerant, Codable-free parsing of the canonical surfaces wire +// format (schema v1) produced by com.codename1.surfaces.SurfaceSerializer: unknown keys +// are ignored, missing optionals degrade to sensible defaults. + +import Foundation +import SwiftUI +import UIKit + +// MARK: - Typed accessors over JSONSerialization output + +func cn1Str(_ dict: [String: Any], _ key: String) -> String? { + return dict[key] as? String +} + +func cn1Dict(_ dict: [String: Any], _ key: String) -> [String: Any]? { + return dict[key] as? [String: Any] +} + +func cn1Arr(_ dict: [String: Any], _ key: String) -> [Any]? { + return dict[key] as? [Any] +} + +/// Numbers arrive as NSNumber (Int/Double/Bool are all bridged) or, defensively, strings. +func cn1Double(_ value: Any?) -> Double? { + if let n = value as? NSNumber { + return n.doubleValue + } + if let s = value as? String { + return Double(s) + } + return nil +} + +func cn1Int(_ value: Any?) -> Int? { + if let n = value as? NSNumber { + return n.intValue + } + if let s = value as? String { + return Int(s) + } + return nil +} + +func cn1CGFloat(_ value: Any?) -> CGFloat? { + if let d = cn1Double(value) { + return CGFloat(d) + } + return nil +} + +func cn1Bool(_ value: Any?) -> Bool { + if let n = value as? NSNumber { + return n.boolValue + } + if let s = value as? String { + return s == "true" + } + return false +} + +/// Epoch-milliseconds value (the wire format for all dates) to Date. +func cn1DateMs(_ value: Any?) -> Date? { + guard let ms = cn1Double(value) else { + return nil + } + return Date(timeIntervalSince1970: ms / 1000.0) +} + +func cn1ParseJSONObject(_ json: String?) -> [String: Any] { + guard let json = json, let data = json.data(using: .utf8) else { + return [:] + } + let parsed = try? JSONSerialization.jsonObject(with: data, options: []) + return parsed as? [String: Any] ?? [:] +} + +func cn1LoadJSONObject(_ url: URL) -> [String: Any]? { + guard let data = try? Data(contentsOf: url) else { + return nil + } + let parsed = try? JSONSerialization.jsonObject(with: data, options: []) + return parsed as? [String: Any] +} + +// MARK: - State interpolation + +/// Replaces ${key} placeholders with the matching state value; unknown keys resolve to "". +func cn1Interpolate(_ template: String, state: [String: Any]) -> String { + guard template.contains("${") else { + return template + } + var out = "" + var rest = Substring(template) + while let start = rest.range(of: "${") { + out += rest[.. UIColor { + let v = UInt32(bitPattern: Int32(truncatingIfNeeded: argb)) + let a = CGFloat((v >> 24) & 0xff) / 255.0 + let r = CGFloat((v >> 16) & 0xff) / 255.0 + let g = CGFloat((v >> 8) & 0xff) / 255.0 + let b = CGFloat(v & 0xff) / 255.0 + return UIColor(red: r, green: g, blue: b, alpha: a) +} + +/// Color spec: {"l": argb, "d": argb} (light/dark pair, resolved via trait collection) +/// or {"role": "label|secondaryLabel|background|accent"} (semantic role). +func cn1Color(_ spec: Any?) -> Color? { + guard let dict = spec as? [String: Any] else { + return nil + } + if let role = dict["role"] as? String { + switch role { + case "label": + return Color.primary + case "secondaryLabel": + return Color.secondary + case "background": + return Color(UIColor.systemBackground) + case "accent": + return Color.accentColor + default: + return nil + } + } + guard let light = cn1Int(dict["l"]) else { + return nil + } + let dark = cn1Int(dict["d"]) ?? light + return Color(UIColor { trait in + trait.userInterfaceStyle == .dark ? cn1UIColor(argb: dark) : cn1UIColor(argb: light) + }) +} + +// MARK: - Vector node parsing + +/// Coordinate-pair list of a vector path op ("pts": [x0, y0, x1, y1, ...]). +func cn1PointList(_ value: Any?) -> [CGPoint] { + guard let arr = value as? [Any] else { + return [] + } + var out: [CGPoint] = [] + var i = 0 + while i + 1 < arr.count { + if let x = cn1CGFloat(arr[i]), let y = cn1CGFloat(arr[i + 1]) { + out.append(CGPoint(x: x, y: y)) + } + i += 2 + } + return out +} + +/// Converts a clock-convention angle (the surfaces wire convention: degrees, 0 = 12 o'clock, +/// clockwise positive) to a SwiftUI Angle. Path angles measure from 3 o'clock advancing +/// clockwise in SwiftUI's y-down coordinate space, so the conversion subtracts 90 degrees. +func cn1ClockAngle(_ clockDegrees: CGFloat) -> Angle { + return .degrees(Double(clockDegrees) - 90) +} + +// MARK: - Fonts and alignment + +func cn1FontWeight(_ value: Any?) -> Font.Weight { + switch value as? String { + case "light": + return .light + case "medium": + return .medium + case "semibold": + return .semibold + case "bold": + return .bold + default: + return .regular + } +} + +func cn1Alignment(_ value: Any?) -> Alignment? { + switch value as? String { + case "topLeading": + return .topLeading + case "top": + return .top + case "topTrailing": + return .topTrailing + case "leading": + return .leading + case "center": + return .center + case "trailing": + return .trailing + case "bottomLeading": + return .bottomLeading + case "bottom": + return .bottom + case "bottomTrailing": + return .bottomTrailing + default: + return nil + } +} + +// MARK: - Actions + +/// Builds the canonical surfaces deep link cn1surface://a?src=..&id=..&p= +/// handled by the Codename One app delegate. +func cn1ActionURL(source: String, action: [String: Any]) -> URL? { + guard let actionId = action["id"] as? String else { + return nil + } + var components = URLComponents() + components.scheme = "cn1surface" + components.host = "a" + var items = [URLQueryItem(name: "src", value: source), URLQueryItem(name: "id", value: actionId)] + if let params = action["p"], JSONSerialization.isValidJSONObject(params), + let data = try? JSONSerialization.data(withJSONObject: params, options: []), + let json = String(data: data, encoding: .utf8) { + items.append(URLQueryItem(name: "p", value: json)) + } + components.queryItems = items + return components.url +} + +// MARK: - Shared container locations + +/// Root of the App Group container, resolved from the generated cn1SurfacesAppGroup constant. +func cn1SurfacesContainerURL() -> URL? { + return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: cn1SurfacesAppGroup) +} + +/// Directory holding one widget kind's published timeline.json + image blobs. +func cn1SurfacesKindDir(_ kind: String) -> URL? { + return cn1SurfacesContainerURL()? + .appendingPathComponent("cn1surfaces", isDirectory: true) + .appendingPathComponent(kind, isDirectory: true) +} + +/// Directory holding image blobs referenced by live activity descriptors. +func cn1SurfacesActivitiesDir() -> URL? { + return cn1SurfacesContainerURL()? + .appendingPathComponent("cn1surfaces", isDirectory: true) + .appendingPathComponent("activities", isDirectory: true) +} + +// MARK: - Timeline document + +struct CN1TimelineEntry { + let date: Date + let state: [String: Any] +} + +struct CN1Timeline { + let kind: String + let reloadAtEnd: Bool + let layouts: [String: Any] + let entries: [CN1TimelineEntry] + + /// Parses a timeline.json document; returns nil when the document is unusable. + static func parse(_ doc: [String: Any]) -> CN1Timeline? { + guard let layouts = cn1Dict(doc, "layouts"), !layouts.isEmpty else { + return nil + } + var entries: [CN1TimelineEntry] = [] + for rawEntry in cn1Arr(doc, "entries") ?? [] { + guard let entryDict = rawEntry as? [String: Any], let date = cn1DateMs(entryDict["date"]) else { + continue + } + entries.append(CN1TimelineEntry(date: date, state: cn1Dict(entryDict, "state") ?? [:])) + } + if entries.isEmpty { + entries.append(CN1TimelineEntry(date: Date(), state: [:])) + } + entries.sort { $0.date < $1.date } + return CN1Timeline( + kind: cn1Str(doc, "kind") ?? "", + reloadAtEnd: (cn1Str(doc, "reload") ?? "atEnd") != "never", + layouts: layouts, + entries: entries) + } + + static func load(kind: String) -> CN1Timeline? { + guard let dir = cn1SurfacesKindDir(kind), + let doc = cn1LoadJSONObject(dir.appendingPathComponent("timeline.json")) else { + return nil + } + return parse(doc) + } + + /// The entry that should be visible now: the latest entry whose date has passed, + /// or the first entry when they are all in the future. + func currentEntry(at now: Date = Date()) -> CN1TimelineEntry { + var current = entries[0] + for entry in entries where entry.date <= now { + current = entry + } + return current + } +} diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceRenderer.swift b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceRenderer.swift new file mode 100644 index 00000000000..8557904ca4e --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceRenderer.swift @@ -0,0 +1,545 @@ +// Auto-generated by Codename One from the com.codename1.surfaces framework. +// Compiled ONLY into the CN1Widgets extension target (iOS 16.1+). Generic renderer that +// lowers the canonical surfaces node tree (schema v1) to SwiftUI. This is data-driven: +// the same code renders every widget kind and live activity of every app. + +import Foundation +import SwiftUI +import UIKit + +/// Everything a render pass needs besides the node itself. +struct CN1RenderContext { + /// Flat state map of the active timeline entry / live activity update; resolves ${key}. + var state: [String: Any] + /// Directory holding the published PNG blobs, nil when images cannot resolve. + var imagesDir: URL? + /// Widget kind id or live activity type, used as the src of action deep links. + var source: String + /// Whether per-node Link actions work in this context. Small widgets and compact + /// island regions only support the root action (widgetURL / default tap). + var allowLinks: Bool + /// Layout axis of the enclosing container, so weighted children expand along it. + var parentAxis: Axis? +} + +private let cn1MaxDepth = 8 + +/// Renders one node (recursively) to SwiftUI. Unknown node types render as empty views so +/// newer documents degrade gracefully on older renderers. +func cn1RenderNode(_ node: [String: Any], _ ctx: CN1RenderContext, depth: Int = 0) -> AnyView { + if depth > cn1MaxDepth { + return AnyView(EmptyView()) + } + let content: AnyView + switch cn1Str(node, "t") ?? "" { + case "col": + content = cn1RenderStack(node, ctx, depth: depth, axis: .vertical) + case "row": + content = cn1RenderStack(node, ctx, depth: depth, axis: .horizontal) + case "box": + content = cn1RenderBox(node, ctx, depth: depth) + case "text": + content = cn1RenderText(node, ctx) + case "dyn": + content = cn1RenderDynamicText(node, ctx) + case "img": + content = cn1RenderImage(node, ctx) + case "prog": + content = cn1RenderProgress(node, ctx) + case "vec": + content = cn1RenderVector(node, ctx) + case "spacer": + content = AnyView(Spacer(minLength: cn1CGFloat(node["min"]) ?? 0)) + default: + content = AnyView(EmptyView()) + } + return cn1ApplyModifiers(content, node: node, ctx: ctx) +} + +// MARK: - Containers + +private func cn1RenderStack(_ node: [String: Any], _ ctx: CN1RenderContext, depth: Int, axis: Axis) -> AnyView { + let children = cn1Children(node) + let spacing = cn1CGFloat(node["spacing"]) ?? 0 + var childCtx = ctx + childCtx.parentAxis = axis + // `align` is a node's alignment inside its PARENT (the API contract), so each child aligns + // itself on this stack's cross axis by its OWN align -- the container's align is consumed by + // ITS parent, not imposed on its children. SwiftUI stacks share one alignment, so a child + // that sets an align is expanded to the full cross-axis extent and pinned inside it, exactly + // like cn1RenderBox does per child; un-aligned children keep their natural size and centre. + if axis == .vertical { + return AnyView(VStack(alignment: .center, spacing: spacing) { + ForEach(0.. AnyView { + guard align is String else { + return view + } + if axis == .vertical { + return AnyView(view.frame(maxWidth: .infinity, + alignment: Alignment(horizontal: cn1HorizontalAlignment(align), vertical: .center))) + } + return AnyView(view.frame(maxHeight: .infinity, + alignment: Alignment(horizontal: .center, vertical: cn1VerticalAlignment(align)))) +} + +private func cn1RenderBox(_ node: [String: Any], _ ctx: CN1RenderContext, depth: Int) -> AnyView { + let children = cn1Children(node) + let boxAlignment = cn1Alignment(node["align"]) ?? .center + var childCtx = ctx + childCtx.parentAxis = nil + return AnyView(ZStack(alignment: boxAlignment) { + ForEach(0.. [[String: Any]] { + var out: [[String: Any]] = [] + for child in cn1Arr(node, "ch") ?? [] { + if let dict = child as? [String: Any] { + out.append(dict) + } + } + return out +} + +private func cn1HorizontalAlignment(_ value: Any?) -> HorizontalAlignment { + switch value as? String { + case "topLeading", "leading", "bottomLeading": + return .leading + case "topTrailing", "trailing", "bottomTrailing": + return .trailing + default: + return .center + } +} + +private func cn1VerticalAlignment(_ value: Any?) -> VerticalAlignment { + switch value as? String { + case "topLeading", "top", "topTrailing": + return .top + case "bottomLeading", "bottom", "bottomTrailing": + return .bottom + default: + return .center + } +} + +// MARK: - Text + +private func cn1StyledText(_ text: Text, _ node: [String: Any]) -> AnyView { + let size = cn1CGFloat(node["size"]) ?? 14 + var styled = text.font(.system(size: size, weight: cn1FontWeight(node["fw"]))) + if let color = cn1Color(node["color"]) { + styled = styled.foregroundColor(color) + } + if let maxLines = cn1Int(node["maxLines"]), maxLines > 0 { + return AnyView(styled.lineLimit(maxLines)) + } + return AnyView(styled) +} + +private func cn1RenderText(_ node: [String: Any], _ ctx: CN1RenderContext) -> AnyView { + let raw = cn1Str(node, "text") ?? "" + return cn1StyledText(Text(cn1Interpolate(raw, state: ctx.state)), node) +} + +/// Dynamic text ticks natively on the OS clock with no process wakeups -- the killer +/// feature for countdowns and ETAs. The date comes from a state key ("dateKey") or an +/// epoch-ms literal ("date"). +private func cn1RenderDynamicText(_ node: [String: Any], _ ctx: CN1RenderContext) -> AnyView { + var date: Date? + if let key = cn1Str(node, "dateKey") { + date = cn1DateMs(ctx.state[key]) + } + if date == nil { + date = cn1DateMs(node["date"]) + } + guard let resolved = date else { + return cn1StyledText(Text(""), node) + } + let text: Text + switch cn1Str(node, "style") ?? "timerDown" { + case "timerUp": + // .timer counts up once the date passes (and down while it is still ahead). + text = Text(resolved, style: .timer) + case "time": + text = Text(resolved, style: .time) + case "date": + text = Text(resolved, style: .date) + case "relative": + text = Text(resolved, style: .relative) + default: // timerDown + let now = Date() + if resolved > now { + // timerInterval (iOS 16) stops at zero instead of counting into negative + // time; the .timer style is the pre-16 approximation so these sources + // compile at any deployment target the generated project ends up with. + if #available(iOS 16.0, *) { + text = Text(timerInterval: now...resolved, countsDown: true) + } else { + text = Text(resolved, style: .timer) + } + } else { + text = Text("0:00") + } + } + return cn1StyledText(text, node) +} + +// MARK: - Images + +private let cn1MaxImageDimension: CGFloat = 1024 + +/// Widget extensions run under a tight (~30MB) memory cap: decode from the app group +/// container and downsample anything larger than 1024px on its longest side. +private func cn1LoadImage(_ dir: URL?, name: String) -> UIImage? { + guard let dir = dir, !name.isEmpty else { + return nil + } + let path = dir.appendingPathComponent(name + ".png").path + guard let image = UIImage(contentsOfFile: path) else { + return nil + } + let largest = max(image.size.width, image.size.height) + if largest <= cn1MaxImageDimension { + return image + } + let scale = cn1MaxImageDimension / largest + let target = CGSize(width: image.size.width * scale, height: image.size.height * scale) + let renderer = UIGraphicsImageRenderer(size: target) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: target)) + } +} + +private func cn1RenderImage(_ node: [String: Any], _ ctx: CN1RenderContext) -> AnyView { + guard let ui = cn1LoadImage(ctx.imagesDir, name: cn1Str(node, "name") ?? "") else { + return AnyView(Color.clear) + } + let tint = cn1Color(node["tint"]) + var image = Image(uiImage: ui) + if tint != nil { + image = image.renderingMode(.template) + } + let scaled: AnyView + switch cn1Str(node, "scale") ?? "fit" { + case "fill": + scaled = AnyView(image.resizable().scaledToFill().clipped()) + case "center": + scaled = AnyView(image) + default: // fit + scaled = AnyView(image.resizable().scaledToFit()) + } + if let tint = tint { + return AnyView(scaled.foregroundColor(tint)) + } + return scaled +} + +// MARK: - Progress + +private func cn1RenderProgress(_ node: [String: Any], _ ctx: CN1RenderContext) -> AnyView { + let circular = cn1Str(node, "style") == "circular" + let color = cn1Color(node["color"]) + var view: AnyView + if let start = cn1DateMs(node["start"]), let end = cn1DateMs(node["end"]), end > start { + // Date-interval progress advances natively on the OS clock, like dynamic text. + // The circular style falls back to the same linear bar (no native timer ring). + // timerInterval needs iOS 16; below that the fraction freezes at render time + // (the documented degradation on every non-iOS platform too). + if #available(iOS 16.0, *) { + view = AnyView(ProgressView(timerInterval: start...end, countsDown: false) { + EmptyView() + } currentValueLabel: { + EmptyView() + }.progressViewStyle(.linear)) + } else { + let total = end.timeIntervalSince(start) + let elapsed = Date().timeIntervalSince(start) + let frozen = total > 0 ? min(max(elapsed / total, 0), 1) : 0 + view = AnyView(ProgressView(value: frozen).progressViewStyle(.linear)) + } + } else { + var value = cn1Double(node["value"]) ?? 0 + if let key = cn1Str(node, "valueKey"), let stateValue = cn1Double(ctx.state[key]) { + value = stateValue + } + value = min(max(value, 0), 1) + if circular { + // Gauge needs iOS 16; the linear bar is the documented circular fallback + // below it (matching the Android degradation). + if #available(iOS 16.0, *) { + view = AnyView(Gauge(value: value, in: 0...1) { + EmptyView() + }.gaugeStyle(.accessoryCircularCapacity)) + } else { + view = AnyView(ProgressView(value: value).progressViewStyle(.linear)) + } + } else { + view = AnyView(ProgressView(value: value)) + } + } + if let color = color { + if #available(iOS 15.0, *) { + view = AnyView(view.tint(color)) + } else { + view = AnyView(view.accentColor(color)) + } + } + return view +} + +// MARK: - Vector nodes + +/// Renders a "vec" node with SwiftUI Canvas: the retained op list replays in paint order in the +/// node's view-box coordinate space, scaled to the laid-out bounds preserving aspect ratio (the +/// aspectRatio modifier keeps the proposed size at the view-box ratio, and the in-canvas +/// translate/scale centers the drawing for any residual mismatch). Wire angles use the clock +/// convention: degrees, 0 = 12 o'clock, clockwise positive -- see cn1ClockAngle. +private func cn1RenderVector(_ node: [String: Any], _ ctx: CN1RenderContext) -> AnyView { + guard let vw = cn1CGFloat(node["vw"]), let vh = cn1CGFloat(node["vh"]), vw > 0, vh > 0, + let ops = cn1Arr(node, "ops") else { + return AnyView(EmptyView()) + } + // Canvas/GraphicsContext need iOS 15. The extension's runtime floor is 16.1, but these + // sources must COMPILE at any deployment target the generated project ends up with + // (build variants can stomp the extension target's setting), so gate at runtime like + // CN1SurfaceBridge does rather than relying on the build configuration. + guard #available(iOS 15.0, *) else { + return AnyView(EmptyView()) + } + let state = ctx.state + return AnyView(Canvas { context, size in + let scale = min(size.width / vw, size.height / vh) + guard scale > 0 else { + return + } + context.translateBy(x: (size.width - vw * scale) / 2, y: (size.height - vh * scale) / 2) + context.scaleBy(x: scale, y: scale) + cn1DrawVectorOps(ops, &context, state) + }.aspectRatio(vw / vh, contentMode: .fit)) +} + +@available(iOS 15.0, *) +private func cn1DrawVectorOps(_ ops: [Any], _ context: inout GraphicsContext, _ state: [String: Any]) { + for rawOp in ops { + guard let op = rawOp as? [String: Any], let o = cn1Str(op, "o") else { + continue + } + if o == "rot" { + // rotation groups: copy the context (an independent drawing state over the same + // canvas) and compose translate * rotate * translate about the pivot + var deg = cn1CGFloat(op["deg"]) ?? 0 + if let key = cn1Str(op, "degKey") { + deg = cn1CGFloat(state[key]) ?? 0 + } + let px = cn1CGFloat(op["px"]) ?? 0 + let py = cn1CGFloat(op["py"]) ?? 0 + var inner = context + inner.translateBy(x: px, y: py) + // GraphicsContext rotation advances clockwise in y-down space, matching the wire + inner.rotate(by: .degrees(Double(deg))) + inner.translateBy(x: -px, y: -py) + if let nested = cn1Arr(op, "ops") { + cn1DrawVectorOps(nested, &inner, state) + } + continue + } + let color = cn1Color(op["c"]) ?? Color.primary + let shading = GraphicsContext.Shading.color(color) + let strokeWidth = cn1CGFloat(op["sw"]) ?? 1 + switch o { + case "fillRect": + context.fill(Path(cn1OpRect(op)), with: shading) + case "fillRoundRect": + let corner = cn1CGFloat(op["corner"]) ?? 0 + context.fill(Path(roundedRect: cn1OpRect(op), cornerRadius: corner), with: shading) + case "fillEllipse": + context.fill(Path(ellipseIn: cn1EllipseRect(op)), with: shading) + case "fillArc": + context.fill(cn1ArcPath(op, pie: true), with: shading) + case "strokeEllipse": + context.stroke(Path(ellipseIn: cn1EllipseRect(op)), with: shading, + style: StrokeStyle(lineWidth: strokeWidth)) + case "strokeArc": + context.stroke(cn1ArcPath(op, pie: false), with: shading, + style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)) + case "line": + var path = Path() + path.move(to: CGPoint(x: cn1CGFloat(op["x1"]) ?? 0, y: cn1CGFloat(op["y1"]) ?? 0)) + path.addLine(to: CGPoint(x: cn1CGFloat(op["x2"]) ?? 0, y: cn1CGFloat(op["y2"]) ?? 0)) + context.stroke(path, with: shading, + style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)) + case "fillPath": + if let path = cn1PolyPath(op, close: true) { + context.fill(path, with: shading) + } + case "strokePath": + if let path = cn1PolyPath(op, close: cn1Bool(op["close"])) { + context.stroke(path, with: shading, style: StrokeStyle( + lineWidth: strokeWidth, lineCap: .round, lineJoin: .round)) + } + case "text": + cn1DrawVectorText(op, &context, state, color) + default: + // unknown ops are skipped so newer documents degrade gracefully + break + } + } +} + +private func cn1OpRect(_ op: [String: Any]) -> CGRect { + return CGRect(x: cn1CGFloat(op["x"]) ?? 0, y: cn1CGFloat(op["y"]) ?? 0, + width: cn1CGFloat(op["w"]) ?? 0, height: cn1CGFloat(op["h"]) ?? 0) +} + +private func cn1EllipseRect(_ op: [String: Any]) -> CGRect { + let cx = cn1CGFloat(op["cx"]) ?? 0 + let cy = cn1CGFloat(op["cy"]) ?? 0 + let rx = cn1CGFloat(op["rx"]) ?? 0 + let ry = cn1CGFloat(op["ry"]) ?? 0 + return CGRect(x: cx - rx, y: cy - ry, width: rx * 2, height: ry * 2) +} + +/// Builds an elliptical arc path from wire clock angles as a unit-circle arc scaled to (rx, ry) +/// about the center, so ellipses work through a circular addArc. Note that Path.addArc's +/// `clockwise` flag is inverted in y-down coordinates: clockwise:false sweeps visually +/// clockwise, matching the wire's positive (clockwise) sweeps. +private func cn1ArcPath(_ op: [String: Any], pie: Bool) -> Path { + let cx = cn1CGFloat(op["cx"]) ?? 0 + let cy = cn1CGFloat(op["cy"]) ?? 0 + let rx = cn1CGFloat(op["rx"]) ?? 0 + let ry = cn1CGFloat(op["ry"]) ?? 0 + let start = cn1CGFloat(op["start"]) ?? 0 + let sweep = cn1CGFloat(op["sweep"]) ?? 0 + var path = Path() + if pie { + path.move(to: .zero) + } + path.addArc(center: .zero, radius: 1, startAngle: cn1ClockAngle(start), + endAngle: cn1ClockAngle(start + sweep), clockwise: sweep < 0) + if pie { + path.closeSubpath() + } + return path.applying(CGAffineTransform(translationX: cx, y: cy).scaledBy(x: rx, y: ry)) +} + +private func cn1PolyPath(_ op: [String: Any], close: Bool) -> Path? { + let pts = cn1PointList(op["pts"]) + if pts.count < 2 { + return nil + } + var path = Path() + path.move(to: pts[0]) + for pt in pts.dropFirst() { + path.addLine(to: pt) + } + if close { + path.closeSubpath() + } + return path +} + +/// Draws a vector text op: anchored at the middle of x with the baseline at y (the wire +/// contract), resolved so the measured first baseline can position the text exactly. +@available(iOS 15.0, *) +private func cn1DrawVectorText(_ op: [String: Any], _ context: inout GraphicsContext, + _ state: [String: Any], _ color: Color) { + let value = cn1Interpolate(cn1Str(op, "text") ?? "", state: state) + if value.isEmpty { + return + } + let size = cn1CGFloat(op["size"]) ?? 14 + let text = Text(value) + .font(.system(size: size, weight: cn1FontWeight(op["fw"]))) + .foregroundColor(color) + let resolved = context.resolve(text) + let measured = resolved.measure(in: CGSize(width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude)) + let baseline = resolved.firstBaseline(in: measured) + let x = cn1CGFloat(op["x"]) ?? 0 + let y = cn1CGFloat(op["y"]) ?? 0 + context.draw(resolved, at: CGPoint(x: x - measured.width / 2, y: y - baseline), + anchor: .topLeading) +} + +// MARK: - Shared modifiers + +/// Applies the shared node styling in the framework's canonical order: padding sits inside +/// the background, the background is clipped by the corner radius, fixed sizes wrap the +/// decorated node, weights expand along the parent axis, actions wrap everything. +private func cn1ApplyModifiers(_ view: AnyView, node: [String: Any], ctx: CN1RenderContext) -> AnyView { + var out = view + if let pad = node["pad"] as? [Any], pad.count == 4 { + let insets = EdgeInsets( + top: cn1CGFloat(pad[0]) ?? 0, + leading: cn1CGFloat(pad[3]) ?? 0, + bottom: cn1CGFloat(pad[2]) ?? 0, + trailing: cn1CGFloat(pad[1]) ?? 0) + out = AnyView(out.padding(insets)) + } + let corner = cn1CGFloat(node["corner"]) ?? 0 + if let background = cn1Color(node["bg"]) { + out = AnyView(out.background(RoundedRectangle(cornerRadius: corner).fill(background))) + } else if corner > 0 { + out = AnyView(out.clipShape(RoundedRectangle(cornerRadius: corner))) + } + let width = cn1CGFloat(node["w"]) + let height = cn1CGFloat(node["h"]) + if width != nil || height != nil { + out = AnyView(out.frame(width: width, height: height)) + } + if let weight = cn1Double(node["weight"]), weight > 0 { + // Expand along the parent's layout axis and bias the space distribution. + // layoutPriority approximates proportional weights, the documented LCD. + switch ctx.parentAxis { + case .horizontal: + out = AnyView(out.frame(maxWidth: .infinity)) + case .vertical: + out = AnyView(out.frame(maxHeight: .infinity)) + default: + out = AnyView(out.frame(maxWidth: .infinity, maxHeight: .infinity)) + } + out = AnyView(out.layoutPriority(weight)) + } + if ctx.allowLinks, let action = cn1Dict(node, "action"), + let url = cn1ActionURL(source: ctx.source, action: action) { + out = AnyView(Link(destination: url) { out }) + } + return out +} + +/// Finds the root node's action deep link, applied via .widgetURL so the root action works +/// on families where Link is unsupported (small widgets, lock screen accessories). +func cn1RootActionURL(_ node: [String: Any]?, source: String) -> URL? { + guard let node = node, let action = cn1Dict(node, "action") else { + return nil + } + return cn1ActionURL(source: source, action: action) +} diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1WidgetProvider.swift b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1WidgetProvider.swift new file mode 100644 index 00000000000..e4bc56e7c2b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1WidgetProvider.swift @@ -0,0 +1,72 @@ +// Auto-generated by Codename One from the com.codename1.surfaces framework. +// Compiled ONLY into the CN1Widgets extension target (iOS 16.1+). TimelineProvider that +// reads the timeline document the app published into the App Group container +// (/cn1surfaces//timeline.json) and lowers its entries to WidgetKit +// timeline entries. The provider is generic; each generated CN1Widget_ struct in +// CN1WidgetBundle.swift instantiates it with its own kind id. + +import Foundation +import SwiftUI +import WidgetKit + +struct CN1WidgetEntry: TimelineEntry { + let date: Date + let kind: String + /// Per-family layout documents ("default" plus optional "small"/"medium"/"large"/ + /// "lockscreen"); empty when nothing has been published yet. + let layouts: [String: Any] + /// Flat state map resolving the layout's ${key} placeholders. + let state: [String: Any] + let imagesDir: URL? +} + +struct CN1WidgetProvider: TimelineProvider { + let kind: String + + func placeholder(in context: Context) -> CN1WidgetEntry { + return currentEntry() + } + + func getSnapshot(in context: Context, completion: @escaping (CN1WidgetEntry) -> Void) { + completion(currentEntry()) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + guard let timeline = CN1Timeline.load(kind: kind) else { + // Nothing published yet: show the placeholder and wait for the app's next + // publish (which triggers a WidgetCenter reload). + completion(Timeline(entries: [emptyEntry()], policy: .never)) + return + } + let now = Date() + let imagesDir = cn1SurfacesKindDir(kind) + // One entry for "now" (the currently active document entry) plus one WidgetKit + // entry per future flip date; past entries are collapsed into the current one. + let current = timeline.currentEntry(at: now) + var entries = [CN1WidgetEntry(date: now, kind: kind, layouts: timeline.layouts, + state: current.state, imagesDir: imagesDir)] + var hasFutureEntries = false + for entry in timeline.entries where entry.date > now { + hasFutureEntries = true + entries.append(CN1WidgetEntry(date: entry.date, kind: kind, layouts: timeline.layouts, + state: entry.state, imagesDir: imagesDir)) + } + // .atEnd with no future entries would make WidgetKit reload in a tight loop; + // an exhausted timeline waits for the next app-side publish instead. + let policy: TimelineReloadPolicy = (timeline.reloadAtEnd && hasFutureEntries) ? .atEnd : .never + completion(Timeline(entries: entries, policy: policy)) + } + + private func currentEntry() -> CN1WidgetEntry { + guard let timeline = CN1Timeline.load(kind: kind) else { + return emptyEntry() + } + let entry = timeline.currentEntry() + return CN1WidgetEntry(date: Date(), kind: kind, layouts: timeline.layouts, + state: entry.state, imagesDir: cn1SurfacesKindDir(kind)) + } + + private func emptyEntry() -> CN1WidgetEntry { + return CN1WidgetEntry(date: Date(), kind: kind, layouts: [:], state: [:], imagesDir: nil) + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceRasterizerTest.java b/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceRasterizerTest.java new file mode 100644 index 00000000000..c0b3ec0f72b --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceRasterizerTest.java @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Covers the pure logic of {@link SurfaceRasterizer} that runs without a platform Display: + * timeline entry selection and flip scheduling, per-size layout lookup, dynamic-text formatting, + * placeholder interpolation and wire color resolution. Pixel output is exercised manually through + * the simulator's Widgets preview window. + */ +class SurfaceRasterizerTest { + + // numbers deliberately mix Long and Double, matching what JSONParser may hand the rasterizer + private static Map timelineDoc() { + Map doc = new LinkedHashMap(); + List entries = new ArrayList(); + entries.add(entry(Long.valueOf(1000L), "first")); + entries.add(entry(Double.valueOf(2000d), "second")); + entries.add(entry(Long.valueOf(3000L), "third")); + doc.put("entries", entries); + return doc; + } + + private static Map entry(Object date, String marker) { + Map e = new LinkedHashMap(); + e.put("date", date); + Map state = new HashMap(); + state.put("marker", marker); + e.put("state", state); + return e; + } + + @SuppressWarnings("unchecked") + private static String markerOf(Map entry) { + return (String) ((Map) entry.get("state")).get("marker"); + } + + // --- currentEntry / nextEntryFlip ------------------------------------------- + + @Test + void currentEntryPicksTheMostRecentlyPassedEntry() { + Map doc = timelineDoc(); + assertEquals("first", markerOf(SurfaceRasterizer.currentEntry(doc, 1500))); + assertEquals("second", markerOf(SurfaceRasterizer.currentEntry(doc, 2500))); + assertEquals("third", markerOf(SurfaceRasterizer.currentEntry(doc, 99999))); + } + + @Test + void currentEntryFallsBackToTheFirstEntryBeforeAnyDatePassed() { + assertEquals("first", markerOf(SurfaceRasterizer.currentEntry(timelineDoc(), 500))); + } + + @Test + void currentEntryReturnsNullWithoutEntries() { + assertNull(SurfaceRasterizer.currentEntry(new LinkedHashMap(), 500)); + assertNull(SurfaceRasterizer.currentEntry(null, 500)); + } + + @Test + void nextEntryFlipReturnsTheEarliestFutureEntry() { + Map doc = timelineDoc(); + assertEquals(1000, SurfaceRasterizer.nextEntryFlip(doc, 500)); + assertEquals(2000, SurfaceRasterizer.nextEntryFlip(doc, 1500)); + assertEquals(3000, SurfaceRasterizer.nextEntryFlip(doc, 2000)); + } + + @Test + void nextEntryFlipReturnsZeroWhenNoEntryIsAhead() { + assertEquals(0, SurfaceRasterizer.nextEntryFlip(timelineDoc(), 3000)); + assertEquals(0, SurfaceRasterizer.nextEntryFlip(null, 0)); + } + + // --- layoutForSize ------------------------------------------------------------- + + @Test + void layoutForSizePrefersTheExplicitSizeAndFallsBackToDefault() { + Map defaultLayout = new LinkedHashMap(); + defaultLayout.put("t", "col"); + Map smallLayout = new LinkedHashMap(); + smallLayout.put("t", "row"); + Map layouts = new LinkedHashMap(); + layouts.put("default", defaultLayout); + layouts.put("small", smallLayout); + Map doc = new LinkedHashMap(); + doc.put("layouts", layouts); + + assertSame(smallLayout, SurfaceRasterizer.layoutForSize(doc, "small")); + assertSame(defaultLayout, SurfaceRasterizer.layoutForSize(doc, "medium")); + assertSame(defaultLayout, SurfaceRasterizer.layoutForSize(doc, null)); + } + + @Test + void layoutForSizeReturnsNullWhenAbsent() { + assertNull(SurfaceRasterizer.layoutForSize(null, "small")); + assertNull(SurfaceRasterizer.layoutForSize(new LinkedHashMap(), "small")); + Map doc = new LinkedHashMap(); + doc.put("layouts", new LinkedHashMap()); + assertNull(SurfaceRasterizer.layoutForSize(doc, "small")); + } + + // --- dynamic text ------------------------------------------------------------------ + + @Test + void timerDownFormatsMinutesSecondsAndClampsAtZero() { + long now = 1000000L; + assertEquals("1:05", SurfaceRasterizer.formatDynamicText("timerDown", now + 65000, now)); + assertEquals("0:00", SurfaceRasterizer.formatDynamicText("timerDown", now - 5000, now)); + assertEquals("1:01:01", + SurfaceRasterizer.formatDynamicText("timerDown", now + 3661000, now)); + } + + @Test + void timerUpCountsElapsedTime() { + long now = 1000000L; + assertEquals("1:05", SurfaceRasterizer.formatDynamicText("timerUp", now - 65000, now)); + assertEquals("0:00", SurfaceRasterizer.formatDynamicText("timerUp", now + 5000, now)); + } + + @Test + void timeStyleRendersTwelveHourClock() { + assertEquals("9:41 AM", SurfaceRasterizer.formatDynamicText("time", at(9, 41), 0)); + assertEquals("12:05 AM", SurfaceRasterizer.formatDynamicText("time", at(0, 5), 0)); + assertEquals("3:07 PM", SurfaceRasterizer.formatDynamicText("time", at(15, 7), 0)); + } + + @Test + void dateStyleRendersMonthAndDay() { + Calendar c = Calendar.getInstance(); + c.set(2026, Calendar.JUNE, 3, 12, 0, 0); + assertEquals("Jun 3", + SurfaceRasterizer.formatDynamicText("date", c.getTimeInMillis(), 0)); + } + + @Test + void relativeStyleUsesMinuteHourDayGranularity() { + long now = 1000000000L; + assertEquals("in 5 min", + SurfaceRasterizer.formatDynamicText("relative", now + 5 * 60000L, now)); + assertEquals("5 min ago", + SurfaceRasterizer.formatDynamicText("relative", now - 5 * 60000L, now)); + assertEquals("in 2 hr", + SurfaceRasterizer.formatDynamicText("relative", now + 2 * 3600000L, now)); + assertEquals("in 1 day", + SurfaceRasterizer.formatDynamicText("relative", now + 25 * 3600000L, now)); + assertEquals("3 days ago", + SurfaceRasterizer.formatDynamicText("relative", now - 72 * 3600000L, now)); + assertEquals("now", SurfaceRasterizer.formatDynamicText("relative", now + 30000, now)); + } + + private static long at(int hour, int minute) { + Calendar c = Calendar.getInstance(); + c.set(2026, Calendar.JANUARY, 15, hour, minute, 0); + return c.getTimeInMillis(); + } + + // --- interpolation -------------------------------------------------------------------- + + @Test + void interpolateReplacesPlaceholdersFromState() { + Map state = new HashMap(); + state.put("status", "Out for delivery"); + state.put("stops", Integer.valueOf(3)); + assertEquals("Out for delivery - 3 stops", + SurfaceRasterizer.interpolate("${status} - ${stops} stops", state)); + } + + @Test + void interpolateRendersMissingKeysAsEmpty() { + assertEquals("Order ", SurfaceRasterizer.interpolate("Order ${missing}", + new HashMap())); + assertEquals("Order ", SurfaceRasterizer.interpolate("Order ${missing}", null)); + } + + @Test + void interpolatePassesPlainTextThrough() { + assertEquals("plain", SurfaceRasterizer.interpolate("plain", null)); + assertEquals("open ${", SurfaceRasterizer.interpolate("open ${", null)); + } + + // --- vector angle conversion and transforms ------------------------------------------- + + /** A point at clock angle d sits at (cos(clockRadians(d)), sin(clockRadians(d))). */ + private static double[] clockUnitVector(double deg) { + double rad = SurfaceRasterizer.clockRadians(deg); + return new double[] {Math.cos(rad), Math.sin(rad)}; + } + + @Test + void clockAngleZeroPointsUpAndAdvancesClockwise() { + double[] up = clockUnitVector(0); + assertEquals(0.0, up[0], 1e-9); + assertEquals(-1.0, up[1], 1e-9); + double[] right = clockUnitVector(90); + assertEquals(1.0, right[0], 1e-9); + assertEquals(0.0, right[1], 1e-9); + double[] down = clockUnitVector(180); + assertEquals(0.0, down[0], 1e-9); + assertEquals(1.0, down[1], 1e-9); + double[] left = clockUnitVector(270); + assertEquals(-1.0, left[0], 1e-9); + assertEquals(0.0, left[1], 1e-9); + } + + @Test + void clockAnglesConvertToCn1ArcConvention() { + // CN1 Graphics arcs: 0 = 3 o'clock, counterclockwise positive + assertEquals(90, SurfaceRasterizer.cn1ArcStartDegrees(0)); + assertEquals(0, SurfaceRasterizer.cn1ArcStartDegrees(90)); + assertEquals(-90, SurfaceRasterizer.cn1ArcStartDegrees(180)); + assertEquals(-180, SurfaceRasterizer.cn1ArcStartDegrees(270)); + // a clockwise sweep is a negative CN1 sweep + assertEquals(-90, SurfaceRasterizer.cn1ArcSweepDegrees(90)); + assertEquals(360, SurfaceRasterizer.cn1ArcSweepDegrees(-360)); + } + + @Test + void composedRotationSpinsPointsClockwiseAroundThePivot() { + double[] identity = new double[] {1, 0, 0, 1, 0, 0}; + // 90 degrees clockwise around (100, 100): the 12 o'clock point moves to 3 o'clock + double[] rot = SurfaceRasterizer.composeRotation(identity, 90, 100, 100); + double[] p = SurfaceRasterizer.transformPoint(rot, 100, 40); + assertEquals(160.0, p[0], 1e-6); + assertEquals(100.0, p[1], 1e-6); + // the pivot itself is a fixed point + double[] pivot = SurfaceRasterizer.transformPoint(rot, 100, 100); + assertEquals(100.0, pivot[0], 1e-6); + assertEquals(100.0, pivot[1], 1e-6); + // nested rotations compose: two 90s make a 180 + double[] twice = SurfaceRasterizer.composeRotation(rot, 90, 100, 100); + double[] p2 = SurfaceRasterizer.transformPoint(twice, 100, 40); + assertEquals(100.0, p2[0], 1e-6); + assertEquals(160.0, p2[1], 1e-6); + } + + @Test + void rotationComposesUnderScaleAndOffset() { + // the view-box mapping of a 200x200 view box into a 100x100 target at offset (10, 20) + double[] base = new double[] {0.5, 0, 0, 0.5, 10, 20}; + double[] rot = SurfaceRasterizer.composeRotation(base, 90, 100, 100); + // pivot maps to the same pixel with or without rotation + double[] pivotPlain = SurfaceRasterizer.transformPoint(base, 100, 100); + double[] pivotRot = SurfaceRasterizer.transformPoint(rot, 100, 100); + assertEquals(pivotPlain[0], pivotRot[0], 1e-6); + assertEquals(pivotPlain[1], pivotRot[1], 1e-6); + // a hand pointing up rotates to point right, scaled and offset + double[] p = SurfaceRasterizer.transformPoint(rot, 100, 40); + assertEquals(10 + 160 * 0.5, p[0], 1e-6); + assertEquals(20 + 100 * 0.5, p[1], 1e-6); + } + + // --- colors -------------------------------------------------------------------------- + + @Test + void resolveColorPicksLightOrDarkArm() { + Map color = new LinkedHashMap(); + // JSONParser hands numbers back as Double/Long; both arms must resolve + color.put("l", Double.valueOf(0xff112233L)); + color.put("d", Long.valueOf(0xff445566L)); + assertEquals(0xff112233, SurfaceRasterizer.resolveColor(color, false, 0)); + assertEquals(0xff445566, SurfaceRasterizer.resolveColor(color, true, 0)); + } + + @Test + void resolveColorResolvesSemanticRoles() { + Map label = new LinkedHashMap(); + label.put("role", "label"); + assertEquals(0xff1c1c1e, SurfaceRasterizer.resolveColor(label, false, 0)); + assertEquals(0xffffffff, SurfaceRasterizer.resolveColor(label, true, 0)); + Map accent = new LinkedHashMap(); + accent.put("role", "accent"); + assertEquals(0xff007aff, SurfaceRasterizer.resolveColor(accent, false, 0)); + assertEquals(0xff007aff, SurfaceRasterizer.resolveColor(accent, true, 0)); + } + + @Test + void resolveColorFallsBackForMissingInput() { + assertEquals(0xffabcdef, SurfaceRasterizer.resolveColor(null, false, 0xffabcdef)); + assertEquals(0xffabcdef, SurfaceRasterizer.resolveColor("nonsense", true, 0xffabcdef)); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceTest.java b/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceTest.java new file mode 100644 index 00000000000..8f95c683e1f --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/surfaces/SurfaceTest.java @@ -0,0 +1,670 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.surfaces; + +import com.codename1.io.JSONParser; +import com.codename1.surfaces.spi.SurfaceBridge; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.StringReader; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Platform-independent coverage for the portable com.codename1.surfaces runtime: descriptor + * serialization round-trips, timeline ordering, image registry dedup, live activity lifecycle + * against a fake {@link SurfaceBridge} and the action dispatch cold-start queue. Needs no + * platform Display. + */ +class SurfaceTest { + + /** Records bridge calls so publishing and live activity behaviour can be asserted. */ + private static final class FakeBridge implements SurfaceBridge { + boolean widgetsSupported = true; + boolean activitiesSupported = true; + String publishedKind; + String publishedJson; + Map publishedImages; + final List registeredKinds = new ArrayList(); + String reloadedKind = "unset"; + String startedJson; + final List updates = new ArrayList(); + String endedId; + String endedFinalState; + boolean endedImmediately; + int nextActivityId = 1; + + public boolean areWidgetsSupported() { + return widgetsSupported; + } + + public boolean isLiveActivitySupported() { + return activitiesSupported; + } + + public void registerWidgetKind(String kindJson) { + registeredKinds.add(kindJson); + } + + public void publishWidgetTimeline(String kindId, String timelineJson, + Map images) { + publishedKind = kindId; + publishedJson = timelineJson; + publishedImages = images; + } + + public void reloadWidgets(String kindId) { + reloadedKind = kindId; + } + + public int getInstalledWidgetCount(String kindId) { + return 2; + } + + public String startLiveActivity(String descriptorJson, Map images) { + startedJson = descriptorJson; + return "act" + (nextActivityId++); + } + + public void updateLiveActivity(String activityId, String stateJson) { + updates.add(activityId + ":" + stateJson); + } + + public void endLiveActivity(String activityId, String finalStateJson, + boolean dismissImmediately) { + endedId = activityId; + endedFinalState = finalStateJson; + endedImmediately = dismissImmediately; + } + } + + // a tiny valid PNG header + payload; EncodedImage wraps bytes lazily so any bytes work here + private static byte[] pngBytes(int seed) { + byte[] b = new byte[64]; + b[0] = (byte) 0x89; + b[1] = 'P'; + b[2] = 'N'; + b[3] = 'G'; + for (int i = 4; i < b.length; i++) { + b[i] = (byte) (i * seed); + } + return b; + } + + @SuppressWarnings("unchecked") + private static Map parse(String json) throws Exception { + return new JSONParser().parseJSON(new StringReader(json)); + } + + private static long asLong(Object o) { + return ((Number) o).longValue(); + } + + @AfterEach + void tearDown() { + Surfaces.reset(); + } + + private SurfaceNode deliveryLayout(Map images) { + String imgName = SurfaceSerializer.registerImageBytes(pngBytes(3), images); + return new SurfaceRow().setSpacing(8).setPadding(8) + .setBackground(SurfaceColor.rgb(0xffffffff, 0xff202020)) + .setCornerRadius(12) + .setAction("openOrder", params("orderId", "A1029")) + .add(new SurfaceImage(imgName).setSize(40, 40)) + .add(new SurfaceColumn().setWeight(1) + .add(new SurfaceText("${statusLabel}").setFontSize(13) + .setFontWeight(SurfaceFontWeight.SEMIBOLD) + .setColor(SurfaceColor.SECONDARY_LABEL).setMaxLines(1)) + .add(new SurfaceDynamicText(SurfaceDynamicText.STYLE_TIMER_DOWN, "eta") + .setFontSize(22).setFontWeight(SurfaceFontWeight.BOLD)) + .add(new SurfaceProgress(SurfaceProgress.STYLE_LINEAR) + .setValueState("progress").setColor(SurfaceColor.ACCENT))) + .add(new SurfaceSpacer()); + } + + private static Map params(String k, Object v) { + Map m = new HashMap(); + m.put(k, v); + return m; + } + + @Test + @SuppressWarnings("unchecked") + void timelineSerializationRoundTrips() throws Exception { + Map images = new LinkedHashMap(); + WidgetTimeline t = new WidgetTimeline() + .setContent(deliveryLayout(images)) + .addEntry(new Date(1760000000000L), stateMap("Out for delivery", 1760000900000L, 0.7)) + .addEntry(new Date(1760000900000L), stateMap("Arriving now", 1760000900000L, 1.0)); + String json = SurfaceSerializer.serializeTimeline("delivery_status", t, images); + + Map doc = parse(json); + assertEquals(1, asLong(doc.get("v"))); + assertEquals("delivery_status", doc.get("kind")); + assertEquals("atEnd", doc.get("reload")); + + Map layouts = (Map) doc.get("layouts"); + Map root = (Map) layouts.get("default"); + assertEquals("row", root.get("t")); + assertEquals(8, asLong(root.get("spacing"))); + assertEquals(12, asLong(root.get("corner"))); + List pad = (List) root.get("pad"); + assertEquals(4, pad.size()); + assertEquals(8, asLong(pad.get(0))); + Map bg = (Map) root.get("bg"); + assertEquals(0xffffffffL, asLong(bg.get("l")) & 0xffffffffL); + assertEquals(0xff202020L, asLong(bg.get("d")) & 0xffffffffL); + Map action = (Map) root.get("action"); + assertEquals("openOrder", action.get("id")); + assertEquals("A1029", ((Map) action.get("p")).get("orderId")); + + List children = (List) root.get("ch"); + assertEquals(3, children.size()); + Map img = (Map) children.get(0); + assertEquals("img", img.get("t")); + String imgName = (String) img.get("name"); + assertTrue(imgName.startsWith("img")); + assertTrue(images.containsKey(imgName)); + + Map col = (Map) children.get(1); + assertEquals("col", col.get("t")); + assertEquals(1, asLong(col.get("weight"))); + List colCh = (List) col.get("ch"); + Map text = (Map) colCh.get(0); + assertEquals("${statusLabel}", text.get("text")); + assertEquals("semibold", text.get("fw")); + assertEquals("secondaryLabel", ((Map) text.get("color")).get("role")); + Map dyn = (Map) colCh.get(1); + assertEquals("dyn", dyn.get("t")); + assertEquals("timerDown", dyn.get("style")); + assertEquals("eta", dyn.get("dateKey")); + Map prog = (Map) colCh.get(2); + assertEquals("linear", prog.get("style")); + assertEquals("progress", prog.get("valueKey")); + + List entries = (List) doc.get("entries"); + assertEquals(2, entries.size()); + Map e0 = (Map) entries.get(0); + assertEquals(1760000000000L, asLong(e0.get("date"))); + Map state = (Map) e0.get("state"); + assertEquals("Out for delivery", state.get("statusLabel")); + assertEquals(1760000900000L, asLong(state.get("eta"))); + + List imageNames = (List) doc.get("images"); + assertEquals(1, imageNames.size()); + assertEquals(imgName, imageNames.get(0)); + } + + private static Map stateMap(String status, long eta, double progress) { + Map m = new HashMap(); + m.put("statusLabel", status); + m.put("eta", Long.valueOf(eta)); + m.put("progress", Double.valueOf(progress)); + return m; + } + + @Test + @SuppressWarnings("unchecked") + void timelineEntriesAreSortedAndEmptyTimelineGetsImplicitEntry() throws Exception { + Map images = new LinkedHashMap(); + WidgetTimeline t = new WidgetTimeline().setContent(new SurfaceText("x")) + .addEntry(new Date(300L), null) + .addEntry(new Date(100L), null) + .addEntry(new Date(200L), null); + Map doc = parse(SurfaceSerializer.serializeTimeline("k", t, images)); + List entries = (List) doc.get("entries"); + assertEquals(100L, asLong(((Map) entries.get(0)).get("date"))); + assertEquals(200L, asLong(((Map) entries.get(1)).get("date"))); + assertEquals(300L, asLong(((Map) entries.get(2)).get("date"))); + + long before = System.currentTimeMillis(); + WidgetTimeline empty = new WidgetTimeline().setContent(new SurfaceText("x")); + Map doc2 = parse(SurfaceSerializer.serializeTimeline("k", empty, images)); + List entries2 = (List) doc2.get("entries"); + assertEquals(1, entries2.size()); + long date = asLong(((Map) entries2.get(0)).get("date")); + assertTrue(date >= before && date <= System.currentTimeMillis()); + } + + @Test + void timelineWithoutContentIsRejected() { + final WidgetTimeline t = new WidgetTimeline().addEntry(new Date(), null); + assertThrows(IllegalArgumentException.class, new org.junit.jupiter.api.function.Executable() { + public void execute() { + SurfaceSerializer.serializeTimeline("k", t, + new LinkedHashMap()); + } + }); + } + + @Test + void nestingDepthIsBounded() { + SurfaceContainer root = new SurfaceColumn(); + SurfaceContainer cur = root; + for (int i = 0; i < 9; i++) { + SurfaceColumn next = new SurfaceColumn(); + cur.add(next); + cur = next; + } + final WidgetTimeline t = new WidgetTimeline().setContent(root); + assertThrows(IllegalArgumentException.class, new org.junit.jupiter.api.function.Executable() { + public void execute() { + SurfaceSerializer.serializeTimeline("k", t, + new LinkedHashMap()); + } + }); + } + + @Test + void identicalImagesShipOnceAndRegisteredNamesShipNothing() throws Exception { + Map images = new LinkedHashMap(); + byte[] png = pngBytes(7); + String name1 = SurfaceSerializer.registerImageBytes(png, images); + String name2 = SurfaceSerializer.registerImageBytes(png, images); + assertEquals(name1, name2); + assertEquals(1, images.size()); + String other = SurfaceSerializer.registerImageBytes(pngBytes(11), images); + assertFalse(other.equals(name1)); + assertEquals(2, images.size()); + + // nodes that reference an already-registered name ship no new bytes, but the + // document's images list still names them so bridges can garbage collect + // persisted blobs the replacement timeline no longer references + SurfaceRow row = new SurfaceRow() + .add(new SurfaceImage(name1)) + .add(new SurfaceImage("imgpreviouslyshipped")); + Map publishImages = new LinkedHashMap(); + WidgetTimeline t = new WidgetTimeline().setContent(row); + Map doc = parse(SurfaceSerializer.serializeTimeline("k", t, publishImages)); + assertTrue(publishImages.isEmpty()); + @SuppressWarnings("unchecked") + List names = (List) doc.get("images"); + assertEquals(2, names.size()); + assertTrue(names.contains(name1)); + assertTrue(names.contains("imgpreviouslyshipped")); + } + + @Test + @SuppressWarnings("unchecked") + void liveActivitySerializesRegionsAndState() throws Exception { + Map images = new LinkedHashMap(); + LiveActivityDescriptor d = new LiveActivityDescriptor("delivery") + .setContent(new SurfaceText("${statusLabel}")) + .setCompactLeading(new SurfaceImage("imgabc")) + .setCompactTrailing(new SurfaceDynamicText( + SurfaceDynamicText.STYLE_TIMER_DOWN, "eta")) + .setExpandedBottom(new SurfaceProgress(SurfaceProgress.STYLE_LINEAR) + .setValueState("progress")) + .setTint(SurfaceColor.rgb(0xff00aa55)) + .setAndroidChannelId("deliveries"); + Map doc = parse(SurfaceSerializer.serializeLiveActivity( + d, stateMap("Out for delivery", 1760000900000L, 0.7), images)); + assertEquals("delivery", doc.get("type")); + assertEquals("deliveries", + ((Map) doc.get("android")).get("channel")); + Map island = (Map) doc.get("island"); + assertEquals("img", ((Map) island.get("compactLeading")).get("t")); + assertEquals("dyn", ((Map) island.get("compactTrailing")).get("t")); + assertEquals("prog", ((Map) island.get("expandedBottom")).get("t")); + assertNull(island.get("minimal")); + Map state = (Map) doc.get("state"); + assertEquals("Out for delivery", state.get("statusLabel")); + } + + @Test + void liveActivityLifecycleAgainstBridge() { + FakeBridge bridge = new FakeBridge(); + Surfaces.setBridge(bridge); + LiveActivity a = LiveActivity.start( + new LiveActivityDescriptor("delivery").setContent(new SurfaceText("${s}")), + params("s", "started")); + assertTrue(a.isActive()); + assertEquals("act1", a.getId()); + assertNotNull(bridge.startedJson); + + a.update(params("s", "moving")); + assertEquals(1, bridge.updates.size()); + assertTrue(bridge.updates.get(0).startsWith("act1:")); + assertTrue(bridge.updates.get(0).contains("moving")); + + a.end(params("s", "done"), true); + assertFalse(a.isActive()); + assertEquals("act1", bridge.endedId); + assertTrue(bridge.endedFinalState.contains("done")); + assertTrue(bridge.endedImmediately); + + // ended handles are inert + a.update(params("s", "late")); + a.end(null); + assertEquals(1, bridge.updates.size()); + } + + @Test + void unsupportedPlatformYieldsInertHandle() { + FakeBridge bridge = new FakeBridge(); + bridge.activitiesSupported = false; + Surfaces.setBridge(bridge); + assertFalse(LiveActivity.isSupported()); + LiveActivity a = LiveActivity.start( + new LiveActivityDescriptor("x").setContent(new SurfaceText("y")), null); + assertFalse(a.isActive()); + assertNull(a.getId()); + a.update(params("k", "v")); + a.end(null); + assertTrue(bridge.updates.isEmpty()); + assertNull(bridge.endedId); + } + + @Test + void publishForwardsToBridgeAndNoBridgeIsNoOp() { + // no bridge: must not throw + Surfaces.publish("k", new WidgetTimeline().setContent(new SurfaceText("x"))); + assertEquals(0, Surfaces.getInstalledWidgetCount("k")); + + FakeBridge bridge = new FakeBridge(); + Surfaces.setBridge(bridge); + Surfaces.registerWidgetKind(new WidgetKind("delivery_status") + .setDisplayName("Delivery").addSupportedSize(WidgetSize.SMALL)); + assertEquals(1, bridge.registeredKinds.size()); + assertTrue(bridge.registeredKinds.get(0).contains("delivery_status")); + + Surfaces.publish("delivery_status", + new WidgetTimeline().setContent(new SurfaceText("${s}"))); + assertEquals("delivery_status", bridge.publishedKind); + assertNotNull(bridge.publishedJson); + assertNotNull(bridge.publishedImages); + assertEquals(2, Surfaces.getInstalledWidgetCount("delivery_status")); + + Surfaces.reloadWidgets(null); + assertNull(bridge.reloadedKind); + } + + @Test + void invalidKindIdsAreRejected() { + assertThrows(IllegalArgumentException.class, new org.junit.jupiter.api.function.Executable() { + public void execute() { + new WidgetKind("Delivery-Status"); + } + }); + assertThrows(IllegalArgumentException.class, new org.junit.jupiter.api.function.Executable() { + public void execute() { + new WidgetKind("1delivery"); + } + }); + assertEquals("delivery_status2", new WidgetKind("delivery_status2").getId()); + } + + @Test + void actionsQueueUntilHandlerRegistersThenFlushInOrder() { + final List received = new ArrayList(); + Surfaces.dispatchAction("delivery_status", "openOrder", params("orderId", "A1")); + Surfaces.dispatchAction("delivery", "callCourier", null); + + Surfaces.setActionHandler(new SurfaceActionHandler() { + public void onSurfaceAction(SurfaceActionEvent evt) { + received.add(evt); + } + }); + // Display is not initialized in unit tests so delivery is direct + assertEquals(2, received.size()); + assertEquals("openOrder", received.get(0).getActionId()); + assertEquals("A1", received.get(0).getParams().get("orderId")); + assertTrue(received.get(0).isColdStart()); + assertEquals("callCourier", received.get(1).getActionId()); + assertTrue(received.get(1).getParams().isEmpty()); + + // with a handler registered, dispatch is immediate and not cold start + Surfaces.dispatchAction("delivery_status", "openOrder", null); + assertEquals(3, received.size()); + assertFalse(received.get(2).isColdStart()); + assertEquals("delivery_status", received.get(2).getSource()); + } + + @Test + void concurrentDispatchAndHandlerInstallStrandsNothing() throws Exception { + // Reproduces the cold-start stranding race: a dispatch reads a null handler and enqueues + // while setActionHandler concurrently installs and drains. If the read+enqueue and the + // install+drain are not one atomic boundary, an event can be added to the queue after the + // drain already ran, stranding it. Hammer the interleaving many times; every dispatched + // event must be delivered exactly once (Display uninitialized -> deliver is synchronous). + final int perRun = 200; + for (int run = 0; run < 150; run++) { + Surfaces.reset(); + final java.util.concurrent.atomic.AtomicInteger delivered = + new java.util.concurrent.atomic.AtomicInteger(); + final SurfaceActionHandler handler = new SurfaceActionHandler() { + public void onSurfaceAction(SurfaceActionEvent evt) { + delivered.incrementAndGet(); + } + }; + final java.util.concurrent.CountDownLatch start = + new java.util.concurrent.CountDownLatch(1); + Thread dispatcher = new Thread(new Runnable() { + public void run() { + try { + start.await(); + } catch (InterruptedException ignore) { + return; + } + for (int i = 0; i < perRun; i++) { + Surfaces.dispatchAction("s", "a", null); + } + } + }); + Thread installer = new Thread(new Runnable() { + public void run() { + try { + start.await(); + } catch (InterruptedException ignore) { + return; + } + Surfaces.setActionHandler(handler); + } + }); + dispatcher.start(); + installer.start(); + start.countDown(); + dispatcher.join(); + installer.join(); + // No rescue flush here on purpose: with the fix, every event dispatched while the + // handler was null was queued and drained by the install (the enqueue and the drain + // share one lock, so an enqueue can never land after the drain while the handler is + // still seen as null), and every later event was delivered directly -- so the count is + // exact with nothing stranded. A rescue setActionHandler() would hide a real leak. + assertEquals(perRun, delivered.get(), + "every dispatched action must be delivered exactly once, nothing stranded (run " + + run + ")"); + } + Surfaces.reset(); + } + + @Test + void stateSerializationSortsKeys() { + Map state = new LinkedHashMap(); + state.put("zebra", Integer.valueOf(1)); + state.put("alpha", Integer.valueOf(2)); + String json = SurfaceSerializer.serializeState(state); + assertTrue(json.indexOf("alpha") < json.indexOf("zebra")); + assertEquals("{}", SurfaceSerializer.serializeState(null)); + } + + @Test + void perSizeLayoutOverridesSerializeSeparately() throws Exception { + Map images = new LinkedHashMap(); + SurfaceNode def = new SurfaceText("default"); + SurfaceNode small = new SurfaceText("small"); + WidgetTimeline t = new WidgetTimeline().setContent(def) + .setContent(WidgetSize.SMALL, small); + Map doc = parse(SurfaceSerializer.serializeTimeline("k", t, images)); + @SuppressWarnings("unchecked") + Map layouts = (Map) doc.get("layouts"); + assertEquals(2, layouts.size()); + assertNotNull(layouts.get("default")); + assertNotNull(layouts.get("small")); + assertNull(layouts.get("medium")); + } + + @Test + @SuppressWarnings("unchecked") + void vectorSerializationRoundTrips() throws Exception { + SurfaceVector vec = new SurfaceVector(200, 100) + .fillEllipse(100, 50, 96, 46, SurfaceColor.rgb(0xffffffff, 0xff202020)) + .fillArc(100, 50, 40, 40, 0, 90, SurfaceColor.ACCENT) + .strokeArc(100, 50, 44, 44, 90, 180, 3.5f, SurfaceColor.SECONDARY_LABEL) + .fillRoundRect(10, 10, 30, 20, 4, SurfaceColor.LABEL) + .fillPath(new float[] {0, 0, 10, 0, 5, 8}, true, SurfaceColor.LABEL) + .beginRotation(90, 100, 50) + .line(100, 50, 100, 10, 6, SurfaceColor.LABEL) + .beginRotation("minuteAngle", 100, 50) + .strokePath(new float[] {0, 0, 4, 4}, false, 2, SurfaceColor.ACCENT) + .endRotation() + .endRotation() + .text("${label}", 100, 80, 14, SurfaceFontWeight.SEMIBOLD, SurfaceColor.LABEL); + WidgetTimeline t = new WidgetTimeline().setContent(vec); + Map doc = parse(SurfaceSerializer.serializeTimeline("clock", t, + new LinkedHashMap())); + Map layouts = (Map) doc.get("layouts"); + Map root = (Map) layouts.get("default"); + assertEquals("vec", root.get("t")); + assertEquals(200, asLong(root.get("vw"))); + assertEquals(100, asLong(root.get("vh"))); + + List ops = (List) root.get("ops"); + assertEquals(7, ops.size()); + + Map ellipse = (Map) ops.get(0); + assertEquals("fillEllipse", ellipse.get("o")); + assertEquals(100.0, ((Number) ellipse.get("cx")).doubleValue()); + assertEquals(46.0, ((Number) ellipse.get("ry")).doubleValue()); + Map ellipseColor = (Map) ellipse.get("c"); + assertEquals(0xffffffffL, asLong(ellipseColor.get("l")) & 0xffffffffL); + assertEquals(0xff202020L, asLong(ellipseColor.get("d")) & 0xffffffffL); + + Map arc = (Map) ops.get(1); + assertEquals("fillArc", arc.get("o")); + assertEquals(0.0, ((Number) arc.get("start")).doubleValue()); + assertEquals(90.0, ((Number) arc.get("sweep")).doubleValue()); + assertEquals("accent", ((Map) arc.get("c")).get("role")); + + Map strokeArc = (Map) ops.get(2); + assertEquals("strokeArc", strokeArc.get("o")); + assertEquals(3.5, ((Number) strokeArc.get("sw")).doubleValue()); + + Map roundRect = (Map) ops.get(3); + assertEquals("fillRoundRect", roundRect.get("o")); + assertEquals(4.0, ((Number) roundRect.get("corner")).doubleValue()); + + Map path = (Map) ops.get(4); + assertEquals("fillPath", path.get("o")); + // JSONParser returns booleans as strings by default; renderers accept both + assertEquals("true", String.valueOf(path.get("close"))); + List pts = (List) path.get("pts"); + assertEquals(6, pts.size()); + assertEquals(10.0, ((Number) pts.get(2)).doubleValue()); + assertEquals(8.0, ((Number) pts.get(5)).doubleValue()); + + Map rot = (Map) ops.get(5); + assertEquals("rot", rot.get("o")); + assertEquals(90.0, ((Number) rot.get("deg")).doubleValue()); + assertEquals(100.0, ((Number) rot.get("px")).doubleValue()); + assertEquals(50.0, ((Number) rot.get("py")).doubleValue()); + List rotOps = (List) rot.get("ops"); + assertEquals(2, rotOps.size()); + Map line = (Map) rotOps.get(0); + assertEquals("line", line.get("o")); + assertEquals(6.0, ((Number) line.get("sw")).doubleValue()); + Map nestedRot = (Map) rotOps.get(1); + assertEquals("rot", nestedRot.get("o")); + assertEquals("minuteAngle", nestedRot.get("degKey")); + assertNull(nestedRot.get("deg")); + List nestedOps = (List) nestedRot.get("ops"); + assertEquals(1, nestedOps.size()); + assertEquals("strokePath", ((Map) nestedOps.get(0)).get("o")); + + Map text = (Map) ops.get(6); + assertEquals("text", text.get("o")); + assertEquals("${label}", text.get("text")); + assertEquals("semibold", text.get("fw")); + assertEquals(14.0, ((Number) text.get("size")).doubleValue()); + } + + @Test + void vectorUnbalancedRotationThrows() { + final SurfaceVector open = new SurfaceVector(100, 100) + .beginRotation(45, 50, 50) + .line(50, 50, 50, 10, 2, SurfaceColor.LABEL); + final WidgetTimeline t = new WidgetTimeline().setContent(open); + assertThrows(IllegalStateException.class, new org.junit.jupiter.api.function.Executable() { + public void execute() { + SurfaceSerializer.serializeTimeline("k", t, + new LinkedHashMap()); + } + }); + assertThrows(IllegalStateException.class, new org.junit.jupiter.api.function.Executable() { + public void execute() { + new SurfaceVector(100, 100).endRotation(); + } + }); + } + + @Test + void vectorOpCountIsCapped() { + SurfaceVector vec = new SurfaceVector(100, 100); + for (int i = 0; i < 513; i++) { + vec.line(0, 0, 100, 100, 1, SurfaceColor.LABEL); + } + final WidgetTimeline t = new WidgetTimeline().setContent(vec); + assertThrows(IllegalArgumentException.class, new org.junit.jupiter.api.function.Executable() { + public void execute() { + SurfaceSerializer.serializeTimeline("k", t, + new LinkedHashMap()); + } + }); + } + + @Test + void kindSerializationIncludesSizesAndDefaults() throws Exception { + WidgetKind k = new WidgetKind("scores"); + Map doc = parse(SurfaceSerializer.serializeKind(k)); + assertEquals("scores", doc.get("id")); + @SuppressWarnings("unchecked") + List sizes = (List) doc.get("sizes"); + assertEquals(2, sizes.size()); + assertEquals("small", sizes.get(0)); + assertEquals("medium", sizes.get(1)); + } +} diff --git a/scripts/android/screenshots/SurfacesRasterizer.png b/scripts/android/screenshots/SurfacesRasterizer.png new file mode 100644 index 00000000000..922b8c27a44 Binary files /dev/null and b/scripts/android/screenshots/SurfacesRasterizer.png differ diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/CertificateWizard.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/CertificateWizard.java index c873670bc52..ef33f735c55 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/CertificateWizard.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/CertificateWizard.java @@ -164,7 +164,7 @@ private void seedCredentialState() { assumedCredentialConfigured = true; String keyId = Preferences.get(PREF_CREDENTIAL_KEY_ID, "Stored"); String issuerId = Preferences.get(PREF_CREDENTIAL_ISSUER_ID, ""); - state = new SigningState(new SigningState.Credential(true, keyId, issuerId), null, null, null, null, null); + state = new SigningState(new SigningState.Credential(true, keyId, issuerId), null, null, null, null, null, null); } private void initTableState() { @@ -456,7 +456,8 @@ private void credentialPage() { r -> { if (r.ok) { state = new SigningState(new SigningState.Credential(true, key.getText(), issuer.getText()), - state.certificates, state.bundleIds, state.devices, state.profiles, state.apnsKeys); + state.certificates, state.bundleIds, state.devices, state.profiles, state.apnsKeys, + state.appGroups); storeCredentialState(); } afterMutation(r, "Credential stored"); @@ -466,7 +467,8 @@ private void credentialPage() { "Delete", () -> service.deleteCredential(r -> { if (r.ok) { state = new SigningState(new SigningState.Credential(false, null, null), - state.certificates, state.bundleIds, state.devices, state.profiles, state.apnsKeys); + state.certificates, state.bundleIds, state.devices, state.profiles, state.apnsKeys, + state.appGroups); storeCredentialState(); } afterMutation(r, "Credential deleted"); @@ -1000,11 +1002,25 @@ private void bundleDialog(String initialIdentifier, String initialName) { } CheckBox push = new CheckBox("Enable Push Notifications"); push.setUIID(uiid("CWFieldLabel")); - d.add(id).add(name).add(push); + CheckBox appGroups = new CheckBox("Enable App Groups (widgets / live activities)"); + appGroups.setUIID(uiid("CWFieldLabel")); + d.add(id).add(name).add(push).add(appGroups); Button save = primary("Register", "modal.bundle.submit"); save.addActionListener(e -> { d.dispose(); - service.createBundleId(id.getText(), name.getText(), push.isSelected(), r -> afterMutation(r, "Bundle ID registered")); + final String bundleIdentifier = id.getText(); + final boolean withGroups = appGroups.isSelected(); + service.createBundleId(bundleIdentifier, name.getText(), push.isSelected(), r -> { + if (!r.ok) { + showPageMessage(r.message, true); + return; + } + if (withGroups) { + enableAppGroupsForBundle(bundleIdentifier); + } else { + afterMutation(r, "Bundle ID registered"); + } + }); }); addDialogActions(d, save); showModal(d); @@ -1191,7 +1207,8 @@ private void autoSetupDefaultProfiles(String bundleIdentifier, String appName) { () -> autoSetupCertificate(bundleIdentifier, appName, PROFILE_APP_STORE, () -> autoSetupMacProject(PROFILE_MAC_STORE, () -> autoSetupMacProject(PROFILE_MAC_DIRECT, - () -> showPageMessage("Automatic signing setup completed.", false))))); + () -> autoSetupWidgetExtension(bundleIdentifier, appName, + () -> showPageMessage("Automatic signing setup completed.", false)))))); } private void autoSetupMacProject(String profileType) { @@ -1224,6 +1241,185 @@ private void autoSetupMacProject(String profileType, Runnable next) { } } + private void enableAppGroupsForBundle(String bundleIdentifier) { + ProjectDefaults defaults = projectDefaults(); + String groupId = resolveAppGroupIdentifier(defaults); + String groupName = defaults.appName + " Shared"; + showPageMessage("Enabling App Groups for " + bundleIdentifier + "...", false); + findOrCreateAppGroup(groupId, groupName, group -> refreshForAutoSetup(() -> { + SigningState.BundleId bundle = findBundleByIdentifier(bundleIdentifier, "IOS"); + if (bundle == null) { + showPageMessage("Bundle ID was created but could not be found after refresh.", true); + return; + } + List ids = new ArrayList(); + ids.add(group.id()); + service.enableAppGroupCapability(bundle.id(), ids, rr -> { + if (!rr.ok) { + showPageMessage(rr.message, true); + return; + } + clearPageMessage(); + ToastBar.showMessage("App Groups enabled", FontImage.MATERIAL_CHECK); + reload(); + }); + })); + } + + private String resolveAppGroupIdentifier(ProjectDefaults defaults) { + String fromManifest = binding == null ? null + : surfacesAppGroup(ProjectIO.readSurfacesManifest(binding.projectDir())); + return firstNonEmpty(fromManifest, WizardDecisions.defaultAppGroup(defaults.packageName)); + } + + private static String surfacesAppGroup(ProjectIO.SurfacesManifest manifest) { + return manifest == null ? null : manifest.appGroup(); + } + + /// Reuses an App Group already known to the current state, otherwise creates it. + private void findOrCreateAppGroup(String identifier, String name, + com.codename1.util.OnComplete onGroup) { + for (SigningState.AppGroup g : state.appGroups) { + if (g.identifier() != null && g.identifier().equals(identifier)) { + onGroup.completed(g); + return; + } + } + service.createAppGroup(identifier, name, r -> { + if (!r.ok || r.value == null) { + showPageMessage(r.ok ? "Server returned no App Group" : r.message, true); + return; + } + onGroup.completed(r.value); + }); + } + + private void autoSetupWidgetExtension(String bundleIdentifier, String appName, Runnable next) { + if (binding == null || ProjectIO.readSurfacesManifest(binding.projectDir()) == null) { + next.run(); + return; + } + ProjectDefaults defaults = projectDefaults(); + final String groupId = resolveAppGroupIdentifier(defaults); + final String extIdentifier = WizardDecisions.widgetExtensionBundleId(defaults.packageName); + if (extIdentifier == null || groupId == null) { + next.run(); + return; + } + showPageMessage("Setting up widget extension signing...", false); + findOrCreateAppGroup(groupId, appName + " Shared", group -> refreshForAutoSetup(() -> + autoSetupWidgetExtensionAfterGroup(bundleIdentifier, appName, defaults, groupId, extIdentifier, + group, next))); + } + + private void autoSetupWidgetExtensionAfterGroup(String bundleIdentifier, String appName, + ProjectDefaults defaults, String groupId, String extIdentifier, SigningState.AppGroup group, + Runnable next) { + SigningState.BundleId mainBundle = findBundleByIdentifier(bundleIdentifier, "IOS"); + if (mainBundle == null) { + showPageMessage("Main bundle ID could not be found after refresh.", true); + return; + } + List groupIds = new ArrayList(); + groupIds.add(group.id()); + service.enableAppGroupCapability(mainBundle.id(), groupIds, r -> { + if (!r.ok) { + showPageMessage(r.message, true); + return; + } + ensureWidgetExtensionBundle(appName, defaults, extIdentifier, groupIds, next); + }); + } + + private void ensureWidgetExtensionBundle(String appName, ProjectDefaults defaults, String extIdentifier, + List groupIds, Runnable next) { + SigningState.BundleId ext = findBundleByIdentifier(extIdentifier, "IOS"); + if (ext != null) { + enableWidgetExtensionGroupAndProfile(appName, defaults, extIdentifier, groupIds, next); + return; + } + showPageMessage("Creating widget extension bundle ID " + extIdentifier + "...", false); + service.createBundleId(extIdentifier, appName + " Widgets", true, r -> { + if (!r.ok) { + showPageMessage(r.message, true); + return; + } + refreshForAutoSetup(() -> + enableWidgetExtensionGroupAndProfile(appName, defaults, extIdentifier, groupIds, next)); + }); + } + + private void enableWidgetExtensionGroupAndProfile(String appName, ProjectDefaults defaults, + String extIdentifier, List groupIds, Runnable next) { + SigningState.BundleId ext = findBundleByIdentifier(extIdentifier, "IOS"); + if (ext == null) { + showPageMessage("Widget extension bundle ID could not be found after refresh.", true); + return; + } + service.enableAppGroupCapability(ext.id(), groupIds, r -> { + if (!r.ok) { + showPageMessage(r.message, true); + return; + } + createWidgetExtensionProfile(appName, defaults, extIdentifier, next); + }); + } + + private void createWidgetExtensionProfile(String appName, ProjectDefaults defaults, String extIdentifier, + Runnable next) { + SigningState.Profile existing = findProfile(extIdentifier, PROFILE_APP_STORE); + if (existing != null) { + downloadWidgetExtensionProfile(defaults, existing, next); + return; + } + SigningState.BundleId ext = findBundleByIdentifier(extIdentifier, "IOS"); + List compatible = WizardDecisions.compatibleCertificates(state, PROFILE_APP_STORE); + if (ext == null || compatible.isEmpty()) { + showPageMessage("No distribution certificate was available for the widget extension profile.", true); + return; + } + List certs = new ArrayList(); + certs.add(compatible.get(0).appleCertId()); + String profileName = appName + " Widgets App Store"; + showPageMessage("Creating widget extension provisioning profile...", false); + service.createProfile(profileName, PROFILE_APP_STORE, ext.id(), certs, new ArrayList(), r -> { + if (!r.ok) { + showPageMessage(r.message, true); + return; + } + refreshForAutoSetup(() -> { + SigningState.Profile created = findProfile(extIdentifier, PROFILE_APP_STORE); + if (created == null) { + showPageMessage("Widget extension profile was created but could not be found after refresh.", true); + return; + } + downloadWidgetExtensionProfile(defaults, created, next); + }); + }); + } + + private void downloadWidgetExtensionProfile(ProjectDefaults defaults, SigningState.Profile profile, + Runnable next) { + service.downloadProfile(profile.id(), "CN1Widgets.mobileprovision", r -> { + if (!r.ok) { + showPageMessage(r.message, true); + return; + } + try { + String groupId = resolveAppGroupIdentifier(defaults); + SigningAssetInstaller.applyWidgetExtensionSigning(binding.settings(), groupId, r.value); + clearPageMessage(); + ToastBar.showMessage("Widget extension signing installed", FontImage.MATERIAL_CHECK); + if (next != null) { + next.run(); + } + } catch (Exception ex) { + Log.e(ex); + showPageMessage("Failed to update widget extension settings: " + friendlyMessage(ex), true); + } + }); + } + private void generateAndroidKeystore(String alias, String password, String dname) { if (!canInstallIntoProject()) { return; @@ -1384,7 +1580,7 @@ private ProjectDefaults projectDefaults() { String appName = firstNonEmpty(readSetting(settings, "codename1.displayName"), readSetting(settings, "codename1.mainName"), "Codename One App"); String bundleId = firstNonEmpty(packageName, stripTeamPrefix(iosAppId), "com.example.app"); - return new ProjectDefaults(appName, bundleId); + return new ProjectDefaults(appName, bundleId, firstNonEmpty(packageName, bundleId)); } private String readSetting(String settingsPath, String key) { @@ -1477,7 +1673,7 @@ static SigningState preserveCachedCredentialDetails(SigningState current, Signin } SigningState.Credential credential = new SigningState.Credential(true, keyId, issuerId); return new SigningState(credential, refreshed.certificates, refreshed.bundleIds, refreshed.devices, - refreshed.profiles, refreshed.apnsKeys); + refreshed.profiles, refreshed.apnsKeys, refreshed.appGroups); } private void storeCredentialState() { @@ -2301,10 +2497,13 @@ private String uiid(String id) { private static final class ProjectDefaults { final String appName; final String bundleId; + final String packageName; - ProjectDefaults(String appName, String bundleId) { + ProjectDefaults(String appName, String bundleId, String packageName) { this.appName = appName == null || appName.trim().isEmpty() ? "Codename One App" : appName.trim(); this.bundleId = bundleId == null || bundleId.trim().isEmpty() ? "com.example.app" : bundleId.trim(); + this.packageName = packageName == null || packageName.trim().isEmpty() + ? this.bundleId : packageName.trim(); } } diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/CloudSigningService.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/CloudSigningService.java index 81e9c649792..9bd3af4bbe6 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/CloudSigningService.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/CloudSigningService.java @@ -1,6 +1,7 @@ package com.codename1.certificatewizard.api; import com.codename1.certificatewizard.cloud.APNsKeysApi; +import com.codename1.certificatewizard.cloud.AppGroupsApi; import com.codename1.certificatewizard.cloud.BundleIDsApi; import com.codename1.certificatewizard.cloud.CertificatesApi; import com.codename1.certificatewizard.cloud.CredentialApi; @@ -8,10 +9,12 @@ import com.codename1.certificatewizard.cloud.ProfilesApi; import com.codename1.certificatewizard.cloud.model.ApnsKeyRequest; import com.codename1.certificatewizard.cloud.model.ApnsKeyStatus; +import com.codename1.certificatewizard.cloud.model.AppGroupDTO; import com.codename1.certificatewizard.cloud.model.AscCredentialRequest; import com.codename1.certificatewizard.cloud.model.AscCredentialStatus; import com.codename1.certificatewizard.cloud.model.BundleIdDTO; import com.codename1.certificatewizard.cloud.model.CapabilityRequest; +import com.codename1.certificatewizard.cloud.model.CreateAppGroupRequest; import com.codename1.certificatewizard.cloud.model.CertDTO; import com.codename1.certificatewizard.cloud.model.CreateBundleIdRequest; import com.codename1.certificatewizard.cloud.model.CreateCertRequest; @@ -44,6 +47,7 @@ public final class CloudSigningService implements SigningService { private final DevicesApi devicesApi; private final ProfilesApi profilesApi; private final APNsKeysApi apnsKeysApi; + private final AppGroupsApi appGroupsApi; public CloudSigningService(String baseUrl, String token, String downloadDir) { this.baseUrl = baseUrl == null || baseUrl.trim().isEmpty() ? DEFAULT_BASE_URL : baseUrl.trim(); @@ -55,6 +59,7 @@ public CloudSigningService(String baseUrl, String token, String downloadDir) { devicesApi = DevicesApi.of(this.baseUrl); profilesApi = ProfilesApi.of(this.baseUrl); apnsKeysApi = APNsKeysApi.of(this.baseUrl); + appGroupsApi = AppGroupsApi.of(this.baseUrl); } public void refresh(OnComplete> callback) { @@ -68,6 +73,7 @@ public void refresh(OnComplete> callback) { final List[] devices = new List[1]; final List[] profiles = new List[1]; final List[] apns = new List[1]; + final List[] appGroups = new List[1]; credentialApi.getCredential(bearerToken, r1 -> { if (authFailure(r1)) { @@ -80,7 +86,7 @@ public void refresh(OnComplete> callback) { } cred[0] = r1.getResponseData(); if (cred[0] == null || !Boolean.TRUE.equals(cred[0].configured())) { - callback.completed(Result.ok(toState(cred[0], null, null, null, null, null))); + callback.completed(Result.ok(toState(cred[0], null, null, null, null, null, null))); return; } certificatesApi.listCertificates(bearerToken, r2 -> { @@ -133,8 +139,19 @@ public void refresh(OnComplete> callback) { return; } apns[0] = r6.getResponseData(); - callback.completed(Result.ok(toState(cred[0], certs[0], bundles[0], - devices[0], profiles[0], apns[0]))); + appGroupsApi.listAppGroups(bearerToken, r7 -> { + if (authFailure(r7)) { + callback.completed(Result.ok(SigningState.empty())); + return; + } + if (!ok(r7)) { + callback.completed(Result.fail(message(r7))); + return; + } + appGroups[0] = r7.getResponseData(); + callback.completed(Result.ok(toState(cred[0], certs[0], bundles[0], + devices[0], profiles[0], apns[0], appGroups[0]))); + }); }); }); }); @@ -174,7 +191,7 @@ public void createBundleId(String identifier, String name, String platform, bool } BundleIdDTO created = r.getResponseData(); if (push && created != null && created.id() != null) { - bundleIdsApi.enableCapability(created.id(), new CapabilityRequest("PUSH_NOTIFICATIONS"), + bundleIdsApi.enableCapability(created.id(), new CapabilityRequest("PUSH_NOTIFICATIONS", null), bearerToken, rr -> done(rr, callback)); } else { callback.completed(Result.ok(null)); @@ -182,6 +199,28 @@ public void createBundleId(String identifier, String name, String platform, bool }); } + public void createAppGroup(String identifier, String name, OnComplete> callback) { + appGroupsApi.createAppGroup(new CreateAppGroupRequest(identifier, name), bearerToken, r -> { + if (!ok(r)) { + callback.completed(Result.fail(message(r))); + return; + } + AppGroupDTO created = r.getResponseData(); + if (created == null) { + callback.completed(Result.fail("Server returned no App Group")); + return; + } + callback.completed(Result.ok(new SigningState.AppGroup(created.id(), created.identifier(), + created.name()))); + }); + } + + public void enableAppGroupCapability(String bundleIdAppleId, List appGroupIds, + OnComplete> callback) { + bundleIdsApi.enableCapability(bundleIdAppleId, new CapabilityRequest("APP_GROUPS", appGroupIds), + bearerToken, r -> done(r, callback)); + } + public void registerDevice(String name, String udid, OnComplete> callback) { devicesApi.registerDevice(new RegisterDeviceRequest(name, udid, "IOS"), bearerToken, r -> done(r, callback)); } @@ -316,7 +355,8 @@ private static void done(Response r, OnComplete> callback) { } private SigningState toState(AscCredentialStatus cred, List certs, List bundles, - List devices, List profiles, List apns) { + List devices, List profiles, List apns, + List appGroups) { List outCerts = new ArrayList(); if (certs != null) { for (CertDTO c : certs) { @@ -350,9 +390,15 @@ private SigningState toState(AscCredentialStatus cred, List certs, List outApns.add(new SigningState.ApnsKey(a.keyId(), a.teamId(), a.displayName(), a.createdAt())); } } + List outGroups = new ArrayList(); + if (appGroups != null) { + for (AppGroupDTO g : appGroups) { + outGroups.add(new SigningState.AppGroup(g.id(), g.identifier(), g.name())); + } + } SigningState.Credential c = cred == null ? new SigningState.Credential(false, null, null) : new SigningState.Credential(Boolean.TRUE.equals(cred.configured()), cred.keyId(), cred.issuerId()); - return new SigningState(c, outCerts, outBundles, outDevices, outProfiles, outApns); + return new SigningState(c, outCerts, outBundles, outDevices, outProfiles, outApns, outGroups); } private void saveBytes(Response response, String suggestedName, OnComplete> callback) { diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/MockSigningService.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/MockSigningService.java index 47c6f76acd8..a127901c5ba 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/MockSigningService.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/MockSigningService.java @@ -3,7 +3,9 @@ import com.codename1.util.OnComplete; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; public final class MockSigningService implements SigningService { private SigningState.Credential credential = new SigningState.Credential(true, "ABCD1234EF", @@ -13,6 +15,8 @@ public final class MockSigningService implements SigningService { private final List devices = new ArrayList(); private final List profiles = new ArrayList(); private final List apns = new ArrayList(); + private final List appGroups = new ArrayList(); + private final Map> appGroupAssociations = new LinkedHashMap>(); private long seq = 100; public MockSigningService() { @@ -86,6 +90,30 @@ public void createBundleId(String identifier, String name, String platform, bool callback.completed(Result.ok(null)); } + public void createAppGroup(String identifier, String name, OnComplete> callback) { + for (SigningState.AppGroup g : appGroups) { + if (g.identifier() != null && g.identifier().equals(identifier)) { + callback.completed(Result.ok(g)); + return; + } + } + SigningState.AppGroup created = new SigningState.AppGroup("GRP_" + (++seq), identifier, name); + appGroups.add(created); + callback.completed(Result.ok(created)); + } + + public void enableAppGroupCapability(String bundleIdAppleId, List appGroupIds, + OnComplete> callback) { + appGroupAssociations.put(bundleIdAppleId, + appGroupIds == null ? new ArrayList() : new ArrayList(appGroupIds)); + callback.completed(Result.ok(null)); + } + + public List appGroupAssociation(String bundleIdAppleId) { + List assoc = appGroupAssociations.get(bundleIdAppleId); + return assoc == null ? new ArrayList() : new ArrayList(assoc); + } + public void registerDevice(String name, String udid, OnComplete> callback) { devices.add(new SigningState.Device("DEV_" + (++seq), name, udid, "IOS", "ENABLED")); callback.completed(Result.ok(null)); @@ -135,6 +163,8 @@ public void clearSigningData(OnComplete> callback) { devices.clear(); profiles.clear(); apns.clear(); + appGroups.clear(); + appGroupAssociations.clear(); callback.completed(Result.ok(null)); } @@ -147,6 +177,6 @@ public void downloadProfile(Long profileId, String suggestedName, OnComplete> callback); + void createAppGroup(String identifier, String name, OnComplete> callback); + void enableAppGroupCapability(String bundleIdAppleId, List appGroupIds, OnComplete> callback); void registerDevice(String name, String udid, OnComplete> callback); void createProfile(String name, String profileType, String bundleIdAppleId, List certificateAppleIds, List deviceAppleIds, OnComplete> callback); diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/SigningState.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/SigningState.java index bc057a099a5..dc00fced415 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/SigningState.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/SigningState.java @@ -11,15 +11,18 @@ public final class SigningState { public final List devices; public final List profiles; public final List apnsKeys; + public final List appGroups; public SigningState(Credential credential, List certificates, List bundleIds, - List devices, List profiles, List apnsKeys) { + List devices, List profiles, List apnsKeys, + List appGroups) { this.credential = credential == null ? new Credential(false, null, null) : credential; this.certificates = immutable(certificates); this.bundleIds = immutable(bundleIds); this.devices = immutable(devices); this.profiles = immutable(profiles); this.apnsKeys = immutable(apnsKeys); + this.appGroups = immutable(appGroups); } private static List immutable(List values) { @@ -53,7 +56,7 @@ private static boolean isExpiringSoon(Long expiresAt, long now) { } public static SigningState empty() { - return new SigningState(new Credential(false, null, null), null, null, null, null, null); + return new SigningState(new Credential(false, null, null), null, null, null, null, null, null); } public record Credential(boolean configured, String keyId, String issuerId) {} @@ -64,4 +67,5 @@ public record Device(String id, String name, String udid, String platform, Strin public record Profile(Long id, String appleProfileId, String name, String profileType, String bundleId, String uuid, Long expiresAt, String status) {} public record ApnsKey(String keyId, String teamId, String displayName, Long createdAt) {} + public record AppGroup(String id, String identifier, String name) {} } diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/WizardDecisions.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/WizardDecisions.java index 17f5e2df440..4c46965083c 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/WizardDecisions.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/api/WizardDecisions.java @@ -4,9 +4,25 @@ import java.util.List; public final class WizardDecisions { + public static final String WIDGET_EXTENSION_SUFFIX = ".CN1Widgets"; + private WizardDecisions() { } + public static String widgetExtensionBundleId(String mainBundleId) { + if (mainBundleId == null || mainBundleId.trim().isEmpty()) { + return null; + } + return mainBundleId.trim() + WIDGET_EXTENSION_SUFFIX; + } + + public static String defaultAppGroup(String packageName) { + if (packageName == null || packageName.trim().isEmpty()) { + return null; + } + return "group." + packageName.trim(); + } + public static boolean profileRequiresDevices(String profileType) { return "IOS_APP_DEVELOPMENT".equals(profileType) || "IOS_APP_ADHOC".equals(profileType) || "MAC_APP_DEVELOPMENT".equals(profileType) diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/ProjectIO.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/ProjectIO.java index ea1c4328262..98486c481a0 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/ProjectIO.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/ProjectIO.java @@ -1,10 +1,13 @@ package com.codename1.certificatewizard.project; import com.codename1.io.FileSystemStorage; +import com.codename1.io.JSONParser; import com.codename1.io.Util; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Map; public final class ProjectIO { public static final String INPUT_PROPERTY = "certificatewizard.input"; @@ -12,6 +15,45 @@ public final class ProjectIO { private ProjectIO() { } + /// Reads `/src/main/resources/surfaces.json` when present. Returns + /// `null` when the project does not use External Surfaces (no manifest file); + /// a non-null result means the manifest exists (the `appGroup` value may still + /// be null when the manifest does not declare one). + public static SurfacesManifest readSurfacesManifest(String projectDir) { + if (projectDir == null || projectDir.trim().isEmpty()) { + return null; + } + String path = projectDir + "/src/main/resources/surfaces.json"; + FileSystemStorage fs = FileSystemStorage.getInstance(); + String url = fsUrl(path); + if (!fs.exists(url)) { + return null; + } + InputStream in = null; + try { + in = fs.openInputStream(url); + Map json = new JSONParser().parseJSON(new InputStreamReader(in, "UTF-8")); + Object appGroup = json == null ? null : json.get("appGroup"); + return new SurfacesManifest(appGroup == null ? null : appGroup.toString()); + } catch (IOException ex) { + return new SurfacesManifest(null); + } finally { + Util.cleanup(in); + } + } + + public static final class SurfacesManifest { + private final String appGroup; + + SurfacesManifest(String appGroup) { + this.appGroup = appGroup == null || appGroup.trim().isEmpty() ? null : appGroup.trim(); + } + + public String appGroup() { + return appGroup; + } + } + public static ProjectBinding loadBinding() { String path = System.getProperty(INPUT_PROPERTY); if (path == null || path.trim().isEmpty()) { diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/SigningAssetInstaller.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/SigningAssetInstaller.java index ac94856c480..2c3b6ec6abb 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/SigningAssetInstaller.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/SigningAssetInstaller.java @@ -56,6 +56,21 @@ public static void applyMacCertificate(String settingsPath, String p12Path, Stri update(settingsPath, updates); } + public static void applyWidgetExtensionSigning(String settingsPath, String appGroupIdentifier, + String extensionProfilePath) throws IOException { + Map updates = new HashMap(); + if (appGroupIdentifier != null) { + updates.put("codename1.arg.ios.surfaces.appGroup", appGroupIdentifier); + } + if (extensionProfilePath != null) { + updates.put("codename1.ios.appext.CN1Widgets.provision", extensionProfilePath); + } + if (updates.isEmpty()) { + return; + } + update(settingsPath, updates); + } + static void update(String settingsPath, Map updates) throws IOException { String text = read(settingsPath); String[] lines = text.replace("\r\n", "\n").split("\n"); diff --git a/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardModelTest.java b/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardModelTest.java index faeae8fb3ec..20f87b02aba 100644 --- a/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardModelTest.java +++ b/scripts/certificatewizard/common/src/test/java/com/codename1/certificatewizard/CertificateWizardModelTest.java @@ -5,6 +5,9 @@ import com.codename1.certificatewizard.api.SigningState; import com.codename1.certificatewizard.api.WizardDecisions; import com.codename1.certificatewizard.project.ProjectBinding; +import com.codename1.certificatewizard.project.SigningAssetInstaller; +import com.codename1.ui.Display; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; @@ -17,6 +20,13 @@ import static org.junit.jupiter.api.Assertions.*; class CertificateWizardModelTest { + @BeforeAll + static void initDisplay() { + if (Display.getInstance() == null || !Display.isInitialized()) { + Display.init(null); + } + } + @Test void profileRulesMatchAppleProfileTypes() { assertFalse(WizardDecisions.profileRequiresDevices("IOS_APP_STORE")); @@ -38,7 +48,7 @@ void compatibleCertificatesMustBeExportableForAutoSetupReuse() { certs.add(new SigningState.Certificate(2L, "APPLE_EXPORTABLE", "IOS_DISTRIBUTION", "Exportable App Store Certificate", "SER2", now + 300L * 86400000L, "ACTIVE", true)); SigningState state = new SigningState(new SigningState.Credential(true, "KEY", "ISSUER"), - certs, null, null, null, null); + certs, null, null, null, null, null); List compatible = WizardDecisions.compatibleCertificates(state, "IOS_APP_STORE"); @@ -104,9 +114,9 @@ void noTokenRefreshDoesNotEraseCachedCredentialState() { @Test void blankServerCredentialMetadataKeepsCachedDisplayValues() { SigningState current = new SigningState(new SigningState.Credential(true, "ABC123XYZ", "issuer-1"), - null, null, null, null, null); + null, null, null, null, null, null); SigningState refreshed = new SigningState(new SigningState.Credential(true, "", ""), - null, null, null, null, null); + null, null, null, null, null, null); SigningState merged = CertificateWizard.preserveCachedCredentialDetails(current, refreshed); @@ -115,6 +125,112 @@ void blankServerCredentialMetadataKeepsCachedDisplayValues() { assertEquals("issuer-1", merged.credential.issuerId()); } + @Test + void widgetExtensionAndAppGroupDecisionsAreDeterministic() { + assertEquals("com.example.app.CN1Widgets", + WizardDecisions.widgetExtensionBundleId("com.example.app")); + assertNull(WizardDecisions.widgetExtensionBundleId(null)); + assertNull(WizardDecisions.widgetExtensionBundleId(" ")); + assertEquals("group.com.example.app", WizardDecisions.defaultAppGroup("com.example.app")); + assertNull(WizardDecisions.defaultAppGroup("")); + } + + @Test + void createAppGroupFindsOrCreatesAndAppearsInRefreshedState() { + MockSigningService service = new MockSigningService(); + final SigningState.AppGroup[] first = new SigningState.AppGroup[1]; + service.createAppGroup("group.com.example.app", "My App Shared", r -> { + assertTrue(r.ok); + first[0] = r.value; + }); + assertNotNull(first[0]); + assertEquals("group.com.example.app", first[0].identifier()); + + final SigningState[] state = new SigningState[1]; + service.refresh(r -> state[0] = r.value); + int groupCount = state[0].appGroups.size(); + assertTrue(groupCount >= 1); + boolean found = false; + for (SigningState.AppGroup g : state[0].appGroups) { + if ("group.com.example.app".equals(g.identifier())) { + found = true; + } + } + assertTrue(found); + + final SigningState.AppGroup[] second = new SigningState.AppGroup[1]; + service.createAppGroup("group.com.example.app", "My App Shared", r -> second[0] = r.value); + assertEquals(first[0].id(), second[0].id()); + service.refresh(r -> state[0] = r.value); + assertEquals(groupCount, state[0].appGroups.size()); + } + + @Test + void enableAppGroupCapabilityRecordsTheAssociation() { + MockSigningService service = new MockSigningService(); + List groupIds = new ArrayList(); + groupIds.add("GRP_1"); + groupIds.add("GRP_2"); + service.enableAppGroupCapability("BID_A1", groupIds, r -> assertTrue(r.ok)); + assertEquals(groupIds, service.appGroupAssociation("BID_A1")); + assertTrue(service.appGroupAssociation("BID_UNKNOWN").isEmpty()); + } + + @Test + void surfacesAutoSetupPiecesProduceGroupCapabilitiesProfileAndSettings() throws Exception { + MockSigningService service = new MockSigningService(); + String packageName = "com.example.app"; + String groupId = WizardDecisions.defaultAppGroup(packageName); + String extId = WizardDecisions.widgetExtensionBundleId(packageName); + assertEquals("group.com.example.app", groupId); + assertEquals("com.example.app.CN1Widgets", extId); + + final SigningState.AppGroup[] group = new SigningState.AppGroup[1]; + service.createAppGroup(groupId, "My App Shared", r -> group[0] = r.value); + List groupIds = new ArrayList(); + groupIds.add(group[0].id()); + + service.enableAppGroupCapability("BID_A1", groupIds, r -> assertTrue(r.ok)); + service.createBundleId(extId, "My App Widgets", true, r -> assertTrue(r.ok)); + final SigningState[] state = new SigningState[1]; + service.refresh(r -> state[0] = r.value); + String extAppleId = null; + for (SigningState.BundleId b : state[0].bundleIds) { + if (extId.equals(b.identifier())) { + extAppleId = b.id(); + } + } + assertNotNull(extAppleId); + service.enableAppGroupCapability(extAppleId, groupIds, r -> assertTrue(r.ok)); + assertEquals(groupIds, service.appGroupAssociation("BID_A1")); + assertEquals(groupIds, service.appGroupAssociation(extAppleId)); + + List dist = WizardDecisions.compatibleCertificates(state[0], "IOS_APP_STORE"); + assertFalse(dist.isEmpty()); + List certs = new ArrayList(); + certs.add(dist.get(0).appleCertId()); + service.createProfile("My App Widgets App Store", "IOS_APP_STORE", extAppleId, certs, + new ArrayList(), r -> assertTrue(r.ok)); + service.refresh(r -> state[0] = r.value); + boolean extProfile = false; + for (SigningState.Profile p : state[0].profiles) { + if (extId.equals(p.bundleId()) && "IOS_APP_STORE".equals(p.profileType())) { + extProfile = true; + } + } + assertTrue(extProfile); + + Path settings = Files.createTempFile("cn1-settings", ".properties"); + Files.writeString(settings, "codename1.packageName=com.example.app\n", StandardCharsets.UTF_8); + SigningAssetInstaller.applyWidgetExtensionSigning(settings.toString(), groupId, + "/tmp/CN1Widgets.mobileprovision"); + String written = Files.readString(settings, StandardCharsets.UTF_8); + assertTrue(written.contains("codename1.arg.ios.surfaces.appGroup=group.com.example.app")); + assertTrue(written.contains( + "codename1.ios.appext.CN1Widgets.provision=/tmp/CN1Widgets.mobileprovision")); + assertTrue(written.contains("codename1.packageName=com.example.app")); + } + @Test void cloudServiceUsesGeneratedClientForClearSigningData() throws Exception { Path sourcePath = Paths.get("src/main/java/com/codename1/certificatewizard/api/CloudSigningService.java"); diff --git a/scripts/certificatewizard/specs/openapi.json b/scripts/certificatewizard/specs/openapi.json index 92b69cc0525..b9b5117dd15 100644 --- a/scripts/certificatewizard/specs/openapi.json +++ b/scripts/certificatewizard/specs/openapi.json @@ -37,6 +37,10 @@ "name": "Profiles", "description": "Provisioning profiles" }, + { + "name": "App Groups", + "description": "Apple App Groups for widgets and Live Activities" + }, { "name": "APNs Keys", "description": "Token-based APNs auth keys (store-only)" @@ -675,6 +679,81 @@ } } }, + "/appsec/7.0/apple/app-groups": { + "get": { + "tags": [ + "App Groups" + ], + "summary": "List App Groups", + "operationId": "listAppGroups", + "responses": { + "200": { + "description": "App Groups", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppGroupDTO" + } + } + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/NotConfigured" + }, + "502": { + "$ref": "#/components/responses/AppleError" + } + } + }, + "post": { + "tags": [ + "App Groups" + ], + "summary": "Find or create an App Group", + "description": "Find-or-create: if the identifier already exists at Apple, the existing group is returned rather than a 409.", + "operationId": "createAppGroup", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAppGroupRequest" + } + } + } + }, + "responses": { + "201": { + "description": "App Group created or found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppGroupDTO" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/NotConfigured" + }, + "502": { + "$ref": "#/components/responses/AppleError" + } + } + } + }, "/appsec/7.0/apple/apns-keys": { "get": { "tags": [ @@ -1008,7 +1087,49 @@ "properties": { "capabilityType": { "type": "string", - "description": "e.g. PUSH_NOTIFICATIONS." + "description": "e.g. PUSH_NOTIFICATIONS or APP_GROUPS." + }, + "appGroupIds": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "description": "Apple App Group resource ids to associate when capabilityType is APP_GROUPS." + } + } + }, + "AppGroupDTO": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Apple's app-group resource id." + }, + "identifier": { + "type": "string", + "description": "The App Group identifier", + "e.g. group.com.example.app.": null + }, + "name": { + "type": "string" + } + } + }, + "CreateAppGroupRequest": { + "type": "object", + "required": [ + "identifier", + "name" + ], + "properties": { + "identifier": { + "type": "string", + "description": "The App Group identifier", + "e.g. group.com.example.app.": null + }, + "name": { + "type": "string" } } }, diff --git a/scripts/certificatewizard/specs/openapi.yaml b/scripts/certificatewizard/specs/openapi.yaml index dd224cf5d5c..f7eb1b52b29 100644 --- a/scripts/certificatewizard/specs/openapi.yaml +++ b/scripts/certificatewizard/specs/openapi.yaml @@ -38,6 +38,8 @@ tags: description: Registered test devices - name: Profiles description: Provisioning profiles + - name: App Groups + description: Apple App Groups for widgets and Live Activities - name: APNs Keys description: Token-based APNs auth keys (store-only) @@ -367,6 +369,43 @@ paths: "404": { description: No such profile } "502": { $ref: "#/components/responses/AppleError" } + /appsec/7.0/apple/app-groups: + get: + tags: [App Groups] + summary: List App Groups + operationId: listAppGroups + responses: + "200": + description: App Groups + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/AppGroupDTO" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/NotConfigured" } + "502": { $ref: "#/components/responses/AppleError" } + post: + tags: [App Groups] + summary: Find or create an App Group + description: "Find-or-create: if the identifier already exists at Apple, the existing group is returned rather than a 409." + operationId: createAppGroup + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CreateAppGroupRequest" } + responses: + "201": + description: App Group created or found + content: + application/json: + schema: { $ref: "#/components/schemas/AppGroupDTO" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/NotConfigured" } + "502": { $ref: "#/components/responses/AppleError" } + /appsec/7.0/apple/apns-keys: get: tags: [APNs Keys] @@ -523,7 +562,24 @@ components: type: object required: [capabilityType] properties: - capabilityType: { type: string, description: "e.g. PUSH_NOTIFICATIONS." } + capabilityType: { type: string, description: "e.g. PUSH_NOTIFICATIONS or APP_GROUPS." } + appGroupIds: + type: array + nullable: true + items: { type: string } + description: Apple App Group resource ids to associate when capabilityType is APP_GROUPS. + AppGroupDTO: + type: object + properties: + id: { type: string, description: Apple's app-group resource id. } + identifier: { type: string, description: The App Group identifier, e.g. group.com.example.app. } + name: { type: string } + CreateAppGroupRequest: + type: object + required: [identifier, name] + properties: + identifier: { type: string, description: The App Group identifier, e.g. group.com.example.app. } + name: { type: string } DeviceDTO: type: object diff --git a/scripts/hellocodenameone/android/src/main/java/com/codenameone/examples/hellocodenameone/SurfacesRemoteViewsNativeImpl.java b/scripts/hellocodenameone/android/src/main/java/com/codenameone/examples/hellocodenameone/SurfacesRemoteViewsNativeImpl.java new file mode 100644 index 00000000000..f61528b1e2d --- /dev/null +++ b/scripts/hellocodenameone/android/src/main/java/com/codenameone/examples/hellocodenameone/SurfacesRemoteViewsNativeImpl.java @@ -0,0 +1,151 @@ +package com.codenameone.examples.hellocodenameone; + +import android.content.Context; +import android.content.res.Configuration; +import android.view.Gravity; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.RemoteViews; + +import com.codename1.impl.android.AndroidImplementation; +import com.codename1.impl.android.AndroidNativeUtil; +import com.codename1.impl.android.surfaces.CN1SurfaceRenderer; +import com.codename1.impl.android.surfaces.CN1SurfaceStore; + +import org.json.JSONArray; +import org.json.JSONObject; + +/** + * Android implementation of {@link SurfacesRemoteViewsNative}: exercises the REAL widget + * lowering in-process. The timeline is read back from the same CN1SurfaceStore file the + * home-screen provider reads, rendered through CN1SurfaceRenderer into RemoteViews exactly + * like CN1WidgetProvider does, and applied to real views (RemoteViews.apply) so the full + * inflation path of the pre-baked cn1_surface_* layout resources runs. Light and dark render + * through createConfigurationContext so both color resolutions are on screen at once, + * mirroring the light+dark tiles of SurfacesRasterizerScreenshotTest. + */ +public class SurfacesRemoteViewsNativeImpl { + private static final String TAG = "CN1SS"; + + public View createWidgetView(final String kindId, final int widthPx, final int heightPx) { + final View[] result = new View[1]; + // Views inflate on the UI thread like every other peer the port creates. + AndroidImplementation.runOnUiThreadAndBlock(new Runnable() { + public void run() { + try { + result[0] = buildWidgetColumn(kindId, widthPx, heightPx); + } catch (Throwable t) { + android.util.Log.e(TAG, "SurfacesRemoteViews native render failed", t); + } + } + }); + return result[0]; + } + + public String probeTimerRender(final String timelineJson) { + final String[] result = new String[1]; + AndroidImplementation.runOnUiThreadAndBlock(new Runnable() { + public void run() { + try { + Context ctx = AndroidNativeUtil.getContext(); + JSONObject doc = new JSONObject(timelineJson); + JSONObject layout = pickLayout(doc); + JSONObject state = pickState(doc); + if (layout == null) { + result[0] = "error:no layout in timeline JSON"; + return; + } + RemoteViews rv = CN1SurfaceRenderer.render(ctx, layout, state, + "cn1ss_probe", null); + if (rv == null) { + result[0] = "error:render returned null"; + return; + } + // apply too: the Chronometer lowering only fully executes on inflation + View v = rv.apply(ctx, new FrameLayout(ctx)); + result[0] = v == null ? "error:apply returned null" : "ok"; + } catch (Throwable t) { + result[0] = "error:" + t; + } + } + }); + return result[0]; + } + + public boolean isSupported() { + return true; + } + + private View buildWidgetColumn(String kindId, int widthPx, int heightPx) throws Exception { + Context ctx = AndroidNativeUtil.getContext(); + String json = CN1SurfaceStore.readWidgetTimeline(ctx, kindId); + if (json == null) { + android.util.Log.e(TAG, "No published timeline for kind " + kindId); + return null; + } + JSONObject doc = new JSONObject(json); + JSONObject layout = pickLayout(doc); + JSONObject state = pickState(doc); + if (layout == null) { + android.util.Log.e(TAG, "No layout in published timeline for kind " + kindId); + return null; + } + java.io.File imagesDir = CN1SurfaceStore.kindDir(ctx, kindId); + LinearLayout column = new LinearLayout(ctx); + column.setOrientation(LinearLayout.VERTICAL); + column.setGravity(Gravity.CENTER); + // the same neutral backdrop the rasterizer screenshot uses + column.setBackgroundColor(0xff606060); + int gap = Math.max(8, heightPx / 20); + boolean[] darks = {false, true}; + for (boolean dark : darks) { + Context themed = themedContext(ctx, dark); + RemoteViews rv = CN1SurfaceRenderer.render(themed, layout, state, kindId, + imagesDir); + FrameLayout host = new FrameLayout(themed); + View widget = rv.apply(themed, host); + host.addView(widget, new FrameLayout.LayoutParams(widthPx, heightPx)); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(widthPx, heightPx); + if (dark) { + lp.topMargin = gap; + } + column.addView(host, lp); + } + return column; + } + + /** A context whose uiMode night flag is forced, so color resolution picks that palette. */ + private static Context themedContext(Context ctx, boolean dark) { + Configuration config = new Configuration(ctx.getResources().getConfiguration()); + config.uiMode = (config.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) + | (dark ? Configuration.UI_MODE_NIGHT_YES : Configuration.UI_MODE_NIGHT_NO); + return ctx.createConfigurationContext(config); + } + + /** Mirrors CN1WidgetProvider's bucket selection for the suite's medium-sized render. */ + private static JSONObject pickLayout(JSONObject doc) { + JSONObject layouts = doc.optJSONObject("layouts"); + if (layouts == null) { + return null; + } + JSONObject layout = layouts.optJSONObject("medium"); + if (layout == null) { + layout = layouts.optJSONObject("default"); + } + if (layout == null && layouts.keys().hasNext()) { + layout = layouts.optJSONObject((String) layouts.keys().next()); + } + return layout; + } + + /** The first entry's state: the suite publishes a single already-active entry. */ + private static JSONObject pickState(JSONObject doc) { + JSONArray entries = doc.optJSONArray("entries"); + if (entries == null || entries.length() == 0) { + return null; + } + JSONObject entry = entries.optJSONObject(0); + return entry == null ? null : entry.optJSONObject("state"); + } +} diff --git a/scripts/hellocodenameone/common/.gitignore b/scripts/hellocodenameone/common/.gitignore new file mode 100644 index 00000000000..b6ec7743ca8 --- /dev/null +++ b/scripts/hellocodenameone/common/.gitignore @@ -0,0 +1,2 @@ +# Auto-generated Android debug keystore (created by the CN1 android build) +androidCerts/ diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/SurfacesRemoteViewsNative.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/SurfacesRemoteViewsNative.java new file mode 100644 index 00000000000..138dc90658f --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/SurfacesRemoteViewsNative.java @@ -0,0 +1,26 @@ +package com.codenameone.examples.hellocodenameone; + +import com.codename1.system.NativeInterface; +import com.codename1.ui.PeerComponent; + +/// Android-only bridge behind SurfacesRemoteViewsScreenshotTest: renders the widget timeline +/// persisted by the real publish path (Surfaces.publish -> AndroidSurfaceBridge -> +/// CN1SurfaceStore) through the port's CN1SurfaceRenderer into RemoteViews, applies them to +/// real Android views and hands them back wrapped as a PeerComponent. Only the Android module +/// carries a functional implementation; the iOS stub reports unsupported and every other +/// platform has no implementation at all (NativeLookup.create returns null) -- the test gates +/// on the platform name before ever touching this interface. +public interface SurfacesRemoteViewsNative extends NativeInterface { + /// Reads the timeline the app published for `kindId` from the on-device surface store, + /// renders it through CN1SurfaceRenderer once in light and once in dark configuration, + /// applies both RemoteViews to real views sized `widthPx` x `heightPx` and returns the + /// two stacked vertically. Returns null when anything fails (details are logged). + PeerComponent createWidgetView(String kindId, int widthPx, int heightPx); + + /// Renders the default layout of the given serialized timeline JSON (the + /// SurfaceSerializer wire format) into RemoteViews and applies it to a real view without + /// displaying it -- used to assert that Chronometer-backed timerDown/timerUp nodes lower + /// without throwing, since their ticking makes them unfit for a deterministic screenshot. + /// Returns "ok" on success or "error:" plus detail. + String probeTimerRender(String timelineJson); +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index decd63df52f..07a4a0fc16a 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -257,6 +257,18 @@ private static int testTimeoutMs(BaseTest testClass) { new PaletteOverrideThemeScreenshotTest(), new CssGradientsScreenshotTest(), new CssFilterBlurScreenshotTest(), + // External surfaces (com.codename1.surfaces): a deterministic widget descriptor + // rendered through the shared SurfaceRasterizer (the JavaSE/Windows/Linux desktop + // widget renderer) with a pinned clock, so the node-tree -> wire-JSON -> pixels + // pipeline has a visual baseline on every platform. The assertion-only surfaces + // tests (serializer round trip, timeline logic, action dispatch, publish contract) + // run with the other API tests near the end of the suite. + new SurfacesRasterizerScreenshotTest(), + // Android only: the REAL RemoteViews widget lowering (publish -> CN1SurfaceStore + // -> CN1SurfaceRenderer -> RemoteViews.apply on real views) captured through a + // native PeerComponent. Self-skips with a CN1SS SKIPPED line everywhere else, so + // only the Android leg carries a SurfacesRemoteViews golden. + new SurfacesRemoteViewsScreenshotTest(), // Modern maps API: the pure-vector MapView (real OSM basemap, // light/dark styles, marker + shape overlays) and the NativeMap // vector fallback, all rendered against the bundled real San @@ -329,6 +341,16 @@ private static int testTimeoutMs(BaseTest testClass) { new TimeApiTest(), new NanoTimeApiTest(), new FloatingToStringTest(), + // External surfaces assertion tests (no screenshots): the serializer wire format + // round-tripped through JSONParser on the device VM, the timeline-selection helpers + // the desktop widget renderers use, action dispatch (cold-start queue + EDT + // delivery) and the publish contract that must hold whether or not the platform + // provides a SurfaceBridge. The visual counterpart is + // SurfacesRasterizerScreenshotTest above. + new SurfacesSerializerRoundTripTest(), + new SurfacesTimelineLogicTest(), + new SurfacesActionDispatchTest(), + new SurfacesPublishTest(), new MotionSensorDeviceTest(), new CryptoApiTest(), new Java17Tests(), diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesActionDispatchTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesActionDispatchTest.java new file mode 100644 index 00000000000..f09773d28fe --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesActionDispatchTest.java @@ -0,0 +1,112 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.surfaces.SurfaceActionEvent; +import com.codename1.surfaces.SurfaceActionHandler; +import com.codename1.surfaces.Surfaces; +import com.codename1.ui.CN; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// External-surfaces action routing on the real EDT: actions dispatched BEFORE a handler is +/// registered (the widget tap that cold-started the app) must queue, then flush to the handler +/// in arrival order flagged cold start; actions dispatched with a live handler must arrive on +/// the EDT without the flag, params delivered verbatim. This is the exact path the platform +/// ports (widget trampoline activity on Android, deep link on iOS, window click on desktop) +/// drive via Surfaces.dispatchAction. Assertion-only test, no screenshot. +public class SurfacesActionDispatchTest extends BaseTest { + + @Override + public boolean shouldTakeScreenshot() { + return false; + } + + @Override + public boolean runTest() { + // Drain anything an earlier platform event might have queued so the ordering + // assertions below start from a clean slate; clearing the handler afterwards + // re-arms the cold-start queue. + Surfaces.setActionHandler(new SurfaceActionHandler() { + @Override + public void onSurfaceAction(SurfaceActionEvent evt) { + } + }); + Surfaces.setActionHandler(null); + + Map firstParams = new HashMap(); + firstParams.put("orderId", Integer.valueOf(7)); + firstParams.put("label", "first"); + firstParams.put("urgent", Boolean.TRUE); + Map warmParams = new HashMap(); + warmParams.put("label", "warm"); + + // no handler yet: both queue as cold-start events + Surfaces.dispatchAction("cn1ss_status", "cold_one", firstParams); + Surfaces.dispatchAction("cn1ss_status", "cold_two", null); + + final List received = new ArrayList(); + final List onEdt = new ArrayList(); + Surfaces.setActionHandler(new SurfaceActionHandler() { + @Override + public void onSurfaceAction(SurfaceActionEvent evt) { + received.add(evt); + onEdt.add(Boolean.valueOf(CN.isEdt())); + } + }); + + // live handler: delivered (still via the EDT queue) without the cold-start flag + Surfaces.dispatchAction("cn1ss_live", "warm_one", warmParams); + + // every delivery above rides Display.callSerially, so a serial call issued after + // them observes the fully flushed sequence + CN.callSerially(new Runnable() { + @Override + public void run() { + verify(received, onEdt); + } + }); + return true; + } + + private void verify(List received, List onEdt) { + try { + assertEqual(3, received.size(), "delivered action count"); + + SurfaceActionEvent first = received.get(0); + assertEqual("cold_one", first.getActionId(), "queued actions flush in arrival order"); + assertEqual("cn1ss_status", first.getSource(), "first action source"); + assertBool(first.isColdStart(), "pre-handler action is flagged cold start"); + assertEqual(7L, ((Number) first.getParams().get("orderId")).longValue(), + "number param delivered verbatim"); + assertEqual("first", (String) first.getParams().get("label"), + "string param delivered verbatim"); + assertBool(Boolean.TRUE.equals(first.getParams().get("urgent")), + "boolean param delivered verbatim"); + + SurfaceActionEvent second = received.get(1); + assertEqual("cold_two", second.getActionId(), "second queued action follows"); + assertBool(second.isColdStart(), "second pre-handler action is cold start too"); + assertNotNull(second.getParams(), "null params arrive as an empty map"); + assertEqual(0, second.getParams().size(), "null params arrive empty"); + + SurfaceActionEvent third = received.get(2); + assertEqual("warm_one", third.getActionId(), "live-handler action delivered"); + assertEqual("cn1ss_live", third.getSource(), "live-handler action source"); + assertBool(!third.isColdStart(), "live-handler action is not cold start"); + assertEqual("warm", (String) third.getParams().get("label"), + "live-handler params delivered verbatim"); + + for (Boolean edt : onEdt) { + assertBool(Boolean.TRUE.equals(edt), "handler runs on the EDT"); + } + } catch (Throwable t) { + fail("Surfaces action dispatch failed: " + t); + return; + } finally { + Surfaces.setActionHandler(null); + } + done(); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesPublishTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesPublishTest.java new file mode 100644 index 00000000000..43b4db6ae38 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesPublishTest.java @@ -0,0 +1,122 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.surfaces.SurfaceColor; +import com.codename1.surfaces.SurfaceColumn; +import com.codename1.surfaces.SurfaceText; +import com.codename1.surfaces.Surfaces; +import com.codename1.surfaces.WidgetKind; +import com.codename1.surfaces.WidgetSize; +import com.codename1.surfaces.WidgetTimeline; +import com.codename1.ui.Display; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// External-surfaces publish contract across the whole platform matrix: registering a widget +/// kind, publishing a tiny timeline, reloading and querying the installed count must be safe +/// no-ops where the platform has no SurfaceBridge (HTML5, TV/watch) and must not throw where a +/// bridge exists (JavaSE simulator preview, Android provider, iOS app-group persist, the +/// Windows/Linux desktop widget windows). Deliberately asserts NO platform-specific support +/// values -- only the invariants every platform shares. The registered kind id mirrors the +/// project's surfaces.json build-time manifest. Assertion-only test, no screenshot. +public class SurfacesPublishTest extends BaseTest { + + @Override + public boolean shouldTakeScreenshot() { + return false; + } + + @Override + public boolean runTest() { + try { + // support probes must never throw, whatever they answer + boolean widgets = Surfaces.areWidgetsSupported(); + System.out.println("CN1SS:INFO:test=SurfacesPublishTest widgets_supported=" + widgets + + " platform=" + Display.getInstance().getPlatformName()); + + // register the kind declared in surfaces.json (the build-time manifest) + Surfaces.registerWidgetKind(new WidgetKind("cn1ss_status") + .setDisplayName("CN1SS Status") + .setDescription("Surfaces suite status widget") + .addSupportedSize(WidgetSize.SMALL) + .addSupportedSize(WidgetSize.MEDIUM)); + List kinds = Surfaces.getRegisteredKinds(); + boolean found = false; + for (WidgetKind k : kinds) { + if ("cn1ss_status".equals(k.getId())) { + found = true; + assertEqual("CN1SS Status", k.getDisplayName(), "registered kind name"); + } + } + assertBool(found, "registered kind is listed"); + + // re-registering the same id replaces rather than duplicates + Surfaces.registerWidgetKind(new WidgetKind("cn1ss_status") + .setDisplayName("CN1SS Status")); + int count = 0; + for (WidgetKind k : Surfaces.getRegisteredKinds()) { + if ("cn1ss_status".equals(k.getId())) { + count++; + } + } + assertEqual(1, count, "re-registered kind is not duplicated"); + + // publishing a tiny timeline must not throw anywhere: it is a no-op without a + // bridge and persists + re-renders where one exists + Map state = new HashMap(); + state.put("status", "cn1ss"); + Surfaces.publish("cn1ss_status", new WidgetTimeline() + .setContent(new SurfaceColumn() + .add(new SurfaceText("cn1ss ${status}") + .setColor(SurfaceColor.LABEL))) + .addEntry(new Date(), state)); + + // installed count is always a non-negative number + int installed = Surfaces.getInstalledWidgetCount("cn1ss_status"); + assertBool(installed >= 0, "installed widget count is non-negative but was " + + installed); + + // reload of all kinds (null) and of an unknown kind must be safe everywhere + Surfaces.reloadWidgets(null); + Surfaces.reloadWidgets("cn1ss_no_such_kind"); + assertBool(Surfaces.getInstalledWidgetCount("cn1ss_no_such_kind") >= 0, + "unknown kind count is non-negative"); + + // live activity lifecycle: start/update/end must be safe on every platform. + // Where unsupported (or the platform refuses, e.g. the iOS simulator test + // harness) start returns an inert handle whose methods are documented + // no-ops; where supported (Android ongoing notification, desktop pill) + // the full cycle must run without throwing. No support value is asserted. + boolean activities = com.codename1.surfaces.LiveActivity.isSupported(); + System.out.println("CN1SS:INFO:test=SurfacesPublishTest activities_supported=" + + activities); + Map laState = new HashMap(); + laState.put("status", "started"); + com.codename1.surfaces.LiveActivity activity = + com.codename1.surfaces.LiveActivity.start( + new com.codename1.surfaces.LiveActivityDescriptor("cn1ss") + .setContent(new SurfaceColumn() + .add(new SurfaceText("cn1ss ${status}") + .setColor(SurfaceColor.LABEL))) + .setCompactLeading(new SurfaceText("cn1ss")) + .setCompactTrailing(new SurfaceText("${status}")), + laState); + assertBool(activity != null, "start returns a handle even when unsupported"); + laState.put("status", "updated"); + activity.update(laState); + laState.put("status", "done"); + activity.end(laState, true); + assertBool(!activity.isActive(), "activity is inactive after end"); + // ended and inert handles tolerate further calls + activity.update(laState); + activity.end(null); + } catch (Throwable t) { + fail("Surfaces publish contract failed: " + t); + return false; + } + done(); + return true; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesRasterizerScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesRasterizerScreenshotTest.java new file mode 100644 index 00000000000..84909b0e368 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesRasterizerScreenshotTest.java @@ -0,0 +1,217 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.io.JSONParser; +import com.codename1.surfaces.SurfaceAlignment; +import com.codename1.surfaces.SurfaceBox; +import com.codename1.surfaces.SurfaceColor; +import com.codename1.surfaces.SurfaceColumn; +import com.codename1.surfaces.SurfaceDynamicText; +import com.codename1.surfaces.SurfaceFontWeight; +import com.codename1.surfaces.SurfaceProgress; +import com.codename1.surfaces.SurfaceRasterizer; +import com.codename1.surfaces.SurfaceRow; +import com.codename1.surfaces.SurfaceSerializer; +import com.codename1.surfaces.SurfaceSpacer; +import com.codename1.surfaces.SurfaceText; +import com.codename1.surfaces.WidgetTimeline; +import com.codename1.ui.Component; +import com.codename1.ui.Font; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.ui.layouts.BorderLayout; + +import java.io.StringReader; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Renders a deterministic widget descriptor through the shared SurfaceRasterizer (the renderer +/// behind the JavaSE simulator preview and the native Windows/Linux desktop widgets) and +/// screenshots the result, so the whole pipeline -- node builders -> SurfaceSerializer wire JSON +/// -> JSONParser -> rasterized pixels -- has a per-platform visual baseline. +/// +/// Determinism: the "now" clock is pinned so every dynamic text formats a constant string, and +/// only the pure-time-difference styles (timerDown/timerUp/relative) are used -- the date/time +/// styles format through Calendar.getInstance() and would depend on the runner's timezone. All +/// colors are explicit 0xFFxxxxxx values (the rasterizer's role colors are hardcoded too, but +/// explicit colors keep the intent obvious). No image nodes: PNG decode fidelity differs per +/// platform and the byte round-trip is covered by SurfacesSerializerRoundTripTest. Text renders +/// with the platform's native: fonts, which differ per OS -- absorbed by the per-platform golden +/// sets like every other text-bearing baseline in the suite. +/// +/// The component re-rasterizes on EVERY paint (the descriptor is parsed once) so the captured +/// frame always reflects settled fonts -- the same guard the graphics tests use against the +/// async native-font load flake. Light and dark renders are shown together with the action hit +/// rectangles outlined. +public class SurfacesRasterizerScreenshotTest extends BaseTest { + private static final String NAME = "SurfacesRasterizer"; + private static final int TILE = 316; + private static final int GAP = 12; + /// Pinned clock: 2025-06-15T15:06:40Z. Only ever used in differences, never formatted + /// through a timezone. + private static final long NOW = 1750000000000L; + + private Map layout; + private Map state; + + @Override + public boolean runTest() { + try { + prepareDescriptor(); + // sanity-check the Result contract once before capturing so a rasterizer + // regression fails loudly instead of shipping a subtly wrong baseline + SurfaceRasterizer.Result probe = SurfaceRasterizer.rasterize( + layout, state, null, TILE, TILE, false, NOW); + assertEqual(TILE, probe.getImage().getWidth(), "rasterized image width"); + assertEqual(TILE, probe.getImage().getHeight(), "rasterized image height"); + assertEqual(TILE * TILE, probe.getArgb().length, "argb pixel count"); + assertEqual(1, probe.getActions().size(), "action hit rect count"); + SurfaceRasterizer.ActionRect hit = probe.getActions().get(0); + assertEqual("open", hit.getActionId(), "action hit rect id"); + assertBool(hit.getWidth() > 0 && hit.getHeight() > 0, "action hit rect has area"); + assertEqual(7L, ((Number) hit.getParams().get("orderId")).longValue(), + "action hit rect params"); + // a countdown is visible, so a re-render is due one second after "now" + assertEqual(NOW + 1000, probe.getNextTickMillis(), "next re-render tick"); + } catch (Throwable t) { + fail("Surfaces rasterizer setup failed: " + t); + return false; + } + + Form form = createForm(NAME, new BorderLayout(), NAME); + form.add(BorderLayout.CENTER, new RasterizerView()); + form.show(); + return true; + } + + /// Builds the fixed descriptor with the real node API, ships it through the serializer and + /// parses it back -- the pixels on screen come from the same wire JSON a platform renderer + /// would consume, not from an in-memory shortcut. + private void prepareDescriptor() throws Exception { + SurfaceColumn root = new SurfaceColumn().setSpacing(6); + root.setPadding(12); + root.setBackground(SurfaceColor.rgb(0xfff2f5f8, 0xff101820)); + root.setCornerRadius(20); + root.add(new SurfaceText("CN1 Surfaces") + .setFontSize(18) + .setFontWeight(SurfaceFontWeight.SEMIBOLD) + .setColor(SurfaceColor.rgb(0xff16324f, 0xffdce6f0)) + .setAlignment(SurfaceAlignment.LEADING)) + .add(new SurfaceRow() + .setSpacing(6) + .add(new SurfaceText("ETA") + .setFontSize(12) + .setColor(SurfaceColor.rgb(0xff5a6b7c, 0xff9aa8b6))) + .add(new SurfaceDynamicText(SurfaceDynamicText.STYLE_TIMER_DOWN, "eta") + .setFontSize(16) + .setFontWeight(SurfaceFontWeight.BOLD) + .setColor(SurfaceColor.rgb(0xffb03030, 0xffffb4a0))) + .add(new SurfaceSpacer()) + .add(new SurfaceDynamicText(SurfaceDynamicText.STYLE_TIMER_UP, + new Date(NOW - 90000L)) + .setFontSize(12) + .setColor(SurfaceColor.rgb(0xff3a3a3a, 0xffc0c0c0)))) + .add(new SurfaceProgress(SurfaceProgress.STYLE_LINEAR) + .setValueState("progress") + .setColor(SurfaceColor.rgb(0xff2e7d32, 0xff81c784))) + .add(new SurfaceRow() + .setSpacing(8) + .add(new SurfaceProgress(SurfaceProgress.STYLE_CIRCULAR) + // deterministic: (NOW - start) / (end - start) = 0.3 at the + // pinned clock + .setDateInterval(new Date(NOW - 30000L), new Date(NOW + 70000L)) + .setColor(SurfaceColor.rgb(0xffe65100, 0xffffb74d))) + .add(new SurfaceText("${status}") + .setFontSize(12) + .setColor(SurfaceColor.rgb(0xff1c1c1e, 0xffe8e8e8))) + .add(new SurfaceSpacer()) + .add(new SurfaceDynamicText(SurfaceDynamicText.STYLE_RELATIVE, + new Date(NOW + 45 * 60000L)) + .setFontSize(12) + .setColor(SurfaceColor.rgb(0xff5a6b7c, 0xff9aa8b6)))) + .add(new SurfaceBox() + .add(new SurfaceText("Open order") + .setFontSize(12) + .setFontWeight(SurfaceFontWeight.SEMIBOLD) + .setColor(SurfaceColor.rgb(0xffffffff))) + .setBackground(SurfaceColor.rgb(0xff4a90d9, 0xff2a5a89)) + .setCornerRadius(10) + .setSize(0, 36) + .setAction("open", actionParams())); + + Map published = new HashMap(); + published.put("status", "On the way"); + published.put("eta", Long.valueOf(NOW + 754000L)); // timerDown renders 12:34 + published.put("progress", Double.valueOf(0.62)); + String json = SurfaceSerializer.serializeTimeline("cn1ss_status", + new WidgetTimeline() + .setContent(root) + .addEntry(new Date(NOW - 1000L), published), + new HashMap()); + + Map doc = new JSONParser().parseJSON(new StringReader(json)); + layout = SurfaceRasterizer.layoutForSize(doc, "small"); + Map entry = SurfaceRasterizer.currentEntry(doc, NOW); + state = castMap(entry.get("state")); + } + + private Map actionParams() { + Map params = new HashMap(); + params.put("orderId", Integer.valueOf(7)); + return params; + } + + @SuppressWarnings("unchecked") + private static Map castMap(Object o) { + return (Map) o; + } + + /// Paints the light and dark rasterizations (side by side when the space allows, stacked on + /// narrow screens) over a neutral backdrop, outlining every action hit rectangle. Follows + /// the clean-paint discipline of the graphics tests: state saved/restored, fresh render per + /// paint. + private final class RasterizerView extends Component { + RasterizerView() { + setUIID("GraphicsComponent"); + } + + @Override + public void paint(Graphics g) { + int alpha = g.getAlpha(); + int color = g.getColor(); + Font font = g.getFont(); + g.pushClip(); + try { + g.setAlpha(255); + g.setColor(0x606060); + g.fillRect(getX(), getY(), getWidth(), getHeight()); + boolean sideBySide = getWidth() >= TILE * 2 + GAP * 3; + int lightX = getX() + GAP; + int lightY = getY() + GAP; + int darkX = sideBySide ? lightX + TILE + GAP : lightX; + int darkY = sideBySide ? lightY : lightY + TILE + GAP; + drawTile(g, false, lightX, lightY); + drawTile(g, true, darkX, darkY); + } finally { + g.popClip(); + g.setFont(font); + g.setColor(color); + g.setAlpha(alpha); + } + } + + private void drawTile(Graphics g, boolean dark, int x, int y) { + SurfaceRasterizer.Result result = SurfaceRasterizer.rasterize( + layout, state, null, TILE, TILE, dark, NOW); + g.drawImage(result.getImage(), x, y); + List actions = result.getActions(); + g.setAlpha(255); + g.setColor(0xff00ff); + for (SurfaceRasterizer.ActionRect a : actions) { + g.drawRect(x + a.getX(), y + a.getY(), a.getWidth() - 1, a.getHeight() - 1); + } + } + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesRemoteViewsScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesRemoteViewsScreenshotTest.java new file mode 100644 index 00000000000..40b71b940ce --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesRemoteViewsScreenshotTest.java @@ -0,0 +1,215 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.surfaces.SurfaceAlignment; +import com.codename1.surfaces.SurfaceBox; +import com.codename1.surfaces.SurfaceColor; +import com.codename1.surfaces.SurfaceColumn; +import com.codename1.surfaces.SurfaceDynamicText; +import com.codename1.surfaces.SurfaceFontWeight; +import com.codename1.surfaces.SurfaceProgress; +import com.codename1.surfaces.SurfaceRow; +import com.codename1.surfaces.SurfaceSerializer; +import com.codename1.surfaces.SurfaceSpacer; +import com.codename1.surfaces.SurfaceText; +import com.codename1.surfaces.SurfaceVector; +import com.codename1.surfaces.Surfaces; +import com.codename1.surfaces.WidgetKind; +import com.codename1.surfaces.WidgetSize; +import com.codename1.surfaces.WidgetTimeline; +import com.codename1.system.NativeLookup; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.PeerComponent; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.hellocodenameone.SurfacesRemoteViewsNative; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/// ANDROID-ONLY end-to-end validation of the real RemoteViews widget lowering. The shared +/// SurfaceRasterizer already has a visual baseline on every platform +/// (SurfacesRasterizerScreenshotTest); this test covers the code the home-screen widget +/// actually runs on Android: the timeline goes through the REAL publish path +/// (Surfaces.publish -> AndroidSurfaceBridge -> CN1SurfaceStore on disk), is read back and +/// rendered through CN1SurfaceRenderer into RemoteViews composed from the build's pre-baked +/// cn1_surface_* layout resources, applied to real views (RemoteViews.apply) in light and +/// dark configuration, and displayed in the form through a native PeerComponent -- the +/// Android screenshot path (PixelCopy of the window) captures native peers. +/// +/// Determinism: the descriptor mirrors SurfacesRasterizerScreenshotTest (same pinned NOW, +/// colors and overall structure) with the nodes that tick or read the wall clock on Android +/// swapped for static equivalents: the Chronometer-backed timerDown/timerUp become static +/// texts, the date-interval circular progress becomes an explicit value, and the relative +/// dynamic text is dropped (DateUtils formats it against the real clock). A dyn `date` node +/// stays -- it renders as static text from the pinned date on Android -- and a state-driven +/// vector clock face covers the in-process bitmap vec lowering. The Chronometer path is +/// asserted separately without a screenshot: probeTimerRender renders + applies a +/// timerDown/timerUp timeline and reports failures. +/// +/// Everywhere but Android the test self-skips (CN1SS SKIPPED log, no screenshot, no golden). +public class SurfacesRemoteViewsScreenshotTest extends BaseTest { + private static final String NAME = "SurfacesRemoteViews"; + /// Same pinned clock as SurfacesRasterizerScreenshotTest: 2025-06-15T15:06:40Z. + private static final long NOW = 1750000000000L; + /// The kind declared in the suite's surfaces.json build-time manifest. + private static final String KIND = "cn1ss_status"; + + @Override + public boolean runTest() { + if (!"and".equals(Display.getInstance().getPlatformName())) { + System.out.println("CN1SS:INFO:test=" + NAME + + " status=SKIPPED reason=android-only-remoteviews-lowering platform=" + + Display.getInstance().getPlatformName()); + done(); + return true; + } + SurfacesRemoteViewsNative bridge = NativeLookup.create(SurfacesRemoteViewsNative.class); + if (bridge == null || !bridge.isSupported()) { + fail("SurfacesRemoteViewsNative bridge is missing on Android"); + return false; + } + PeerComponent peer; + try { + Surfaces.registerWidgetKind(new WidgetKind(KIND) + .setDisplayName("CN1SS Status") + .setDescription("Surfaces suite status widget") + .addSupportedSize(WidgetSize.SMALL) + .addSupportedSize(WidgetSize.MEDIUM)); + // the REAL publish path: serializer -> AndroidSurfaceBridge -> CN1SurfaceStore + Surfaces.publish(KIND, new WidgetTimeline() + .setContent(buildDeterministicLayout()) + .addEntry(new Date(NOW - 1000L), publishedState())); + + // Chronometer lowering probe, asserted without a screenshot (countdowns tick) + String probe = bridge.probeTimerRender(SurfaceSerializer.serializeTimeline(KIND, + buildTimerTimeline(), new HashMap())); + assertEqual("ok", probe, "timerDown/timerUp RemoteViews probe"); + + int dw = Display.getInstance().getDisplayWidth(); + int widthPx = dw * 4 / 5; + int heightPx = widthPx * 9 / 16; + peer = bridge.createWidgetView(KIND, widthPx, heightPx); + } catch (Throwable t) { + fail("Surfaces RemoteViews setup failed: " + t); + return false; + } + if (peer == null) { + fail("createWidgetView returned null; see the CN1SS logcat entries"); + return false; + } + Form form = createForm(NAME, new BorderLayout(), NAME); + form.add(BorderLayout.CENTER, peer); + form.show(); + return true; + } + + /// The rasterizer test's descriptor with its wall-clock-dependent nodes replaced by + /// static equivalents so the RemoteViews render is bit-stable across runs. + private SurfaceColumn buildDeterministicLayout() { + SurfaceColumn root = new SurfaceColumn().setSpacing(6); + root.setPadding(12); + root.setBackground(SurfaceColor.rgb(0xfff2f5f8, 0xff101820)); + root.setCornerRadius(20); + root.add(new SurfaceText("CN1 Surfaces") + .setFontSize(18) + .setFontWeight(SurfaceFontWeight.SEMIBOLD) + .setColor(SurfaceColor.rgb(0xff16324f, 0xffdce6f0)) + .setAlignment(SurfaceAlignment.LEADING)) + .add(new SurfaceRow() + .setSpacing(6) + .add(new SurfaceText("ETA") + .setFontSize(12) + .setColor(SurfaceColor.rgb(0xff5a6b7c, 0xff9aa8b6))) + // static stand-in for the ticking countdown (probed separately) + .add(new SurfaceText("${etaText}") + .setFontSize(16) + .setFontWeight(SurfaceFontWeight.BOLD) + .setColor(SurfaceColor.rgb(0xffb03030, 0xffffb4a0))) + .add(new SurfaceSpacer()) + // renders as static text computed from the pinned date on Android + .add(new SurfaceDynamicText(SurfaceDynamicText.STYLE_DATE, + new Date(NOW)) + .setFontSize(12) + .setColor(SurfaceColor.rgb(0xff3a3a3a, 0xffc0c0c0)))) + .add(new SurfaceProgress(SurfaceProgress.STYLE_LINEAR) + .setValueState("progress") + .setColor(SurfaceColor.rgb(0xff2e7d32, 0xff81c784))) + .add(new SurfaceRow() + .setSpacing(8) + // explicit value instead of the rasterizer test's date interval + // (frozen against the real clock on Android); falls back to a + // linear bar per the RemoteViews LCD contract + .add(new SurfaceProgress(SurfaceProgress.STYLE_CIRCULAR) + .setValue(0.3f) + .setColor(SurfaceColor.rgb(0xffe65100, 0xffffb74d))) + .add(new SurfaceText("${status}") + .setFontSize(12) + .setColor(SurfaceColor.rgb(0xff1c1c1e, 0xffe8e8e8))) + .add(new SurfaceSpacer()) + // state-keyed rotation groups exercise the in-process vec bitmap + .add(buildClockFace().setSize(48, 48))) + .add(new SurfaceBox() + .add(new SurfaceText("Open order") + .setFontSize(12) + .setFontWeight(SurfaceFontWeight.SEMIBOLD) + .setColor(SurfaceColor.rgb(0xffffffff))) + .setBackground(SurfaceColor.rgb(0xff4a90d9, 0xff2a5a89)) + .setCornerRadius(10) + .setSize(0, 36) + .setAction("open", actionParams())); + return root; + } + + /// A fixed 4:15 analog clock face: dial, twelve ticks, state-driven hour/minute hands. + private SurfaceVector buildClockFace() { + SurfaceVector face = new SurfaceVector(200, 200) + .fillEllipse(100, 100, 96, 96, SurfaceColor.rgb(0xfffafafa, 0xff1c1c1e)) + .strokeEllipse(100, 100, 96, 96, 4, SurfaceColor.rgb(0xff4a90d9, 0xff2a5a89)); + for (int hour = 0; hour < 12; hour++) { + boolean quarter = hour % 3 == 0; + face.beginRotation(hour * 30f, 100, 100) + .line(100, 10, 100, quarter ? 24 : 18, quarter ? 5 : 3, SurfaceColor.LABEL) + .endRotation(); + } + face.beginRotation("hourAngle", 100, 100) + .line(100, 100, 100, 52, 8, SurfaceColor.LABEL) + .endRotation() + .beginRotation("minuteAngle", 100, 100) + .line(100, 100, 100, 26, 5, SurfaceColor.rgb(0xffb03030, 0xffffb4a0)) + .endRotation() + .fillEllipse(100, 100, 6, 6, SurfaceColor.rgb(0xff4a90d9, 0xff2a5a89)); + return face; + } + + /// A tiny countdown timeline for the no-screenshot Chronometer probe. + private WidgetTimeline buildTimerTimeline() { + Map state = new HashMap(); + state.put("eta", Long.valueOf(NOW + 754000L)); + return new WidgetTimeline() + .setContent(new SurfaceColumn() + .add(new SurfaceDynamicText(SurfaceDynamicText.STYLE_TIMER_DOWN, "eta") + .setFontSize(16)) + .add(new SurfaceDynamicText(SurfaceDynamicText.STYLE_TIMER_UP, + new Date(NOW - 90000L)) + .setFontSize(12))) + .addEntry(new Date(NOW - 1000L), state); + } + + private Map publishedState() { + Map state = new HashMap(); + state.put("status", "On the way"); + state.put("etaText", "12:34"); + state.put("progress", Double.valueOf(0.62)); + // 4:15 in the clock convention: degrees, 0 = 12 o'clock, clockwise positive + state.put("hourAngle", Double.valueOf(127.5)); + state.put("minuteAngle", Double.valueOf(90)); + return state; + } + + private Map actionParams() { + Map params = new HashMap(); + params.put("orderId", Integer.valueOf(7)); + return params; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesSerializerRoundTripTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesSerializerRoundTripTest.java new file mode 100644 index 00000000000..c17e874fef2 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesSerializerRoundTripTest.java @@ -0,0 +1,291 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.io.JSONParser; +import com.codename1.surfaces.SurfaceAlignment; +import com.codename1.surfaces.SurfaceBox; +import com.codename1.surfaces.SurfaceColor; +import com.codename1.surfaces.SurfaceColumn; +import com.codename1.surfaces.SurfaceDynamicText; +import com.codename1.surfaces.SurfaceFontWeight; +import com.codename1.surfaces.SurfaceImage; +import com.codename1.surfaces.SurfaceProgress; +import com.codename1.surfaces.SurfaceRow; +import com.codename1.surfaces.SurfaceSerializer; +import com.codename1.surfaces.SurfaceSpacer; +import com.codename1.surfaces.SurfaceText; +import com.codename1.surfaces.WidgetSize; +import com.codename1.surfaces.WidgetTimeline; +import com.codename1.ui.EncodedImage; + +import java.io.StringReader; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// External-surfaces wire format on the device VM: builds a descriptor tree that touches every +/// node type (column/row/box/text/dynamic text with a state date key/progress with a state value +/// key/image bytes/spacer), serializes it with SurfaceSerializer and parses the JSON back with +/// JSONParser, asserting the parsed structure matches what was built. The same serializer output +/// is consumed by the iOS WidgetKit extension, the Android RemoteViews renderer and the desktop +/// rasterizers, so a translation bug here (ParparVM string/number handling, the JS port's JSON +/// bridge, the Windows/Linux native VMs) silently corrupts every widget. Assertion-only test, +/// no screenshot. +public class SurfacesSerializerRoundTripTest extends BaseTest { + private static final long ENTRY_EARLY = 1750000000000L; + private static final long ENTRY_LATE = 1750000600000L; + + @Override + public boolean shouldTakeScreenshot() { + return false; + } + + @Override + public boolean runTest() { + try { + byte[] pngBytes = fakePngBytes(); + Map images = new LinkedHashMap(); + String json = SurfaceSerializer.serializeTimeline("cn1ss_status", + buildTimeline(pngBytes), images); + + Map doc = new JSONParser().parseJSON(new StringReader(json)); + + // document envelope + assertEqual(1L, asLong(doc.get("v")), "wire version"); + assertEqual("cn1ss_status", (String) doc.get("kind"), "kind id"); + assertEqual("never", (String) doc.get("reload"), "reload policy"); + + // layouts: default + the two explicit per-size overrides, nothing else + Map layouts = asMap(doc.get("layouts"), "layouts"); + assertEqual(3, layouts.size(), "layout count (default + small + lockscreen)"); + checkDefaultLayout(asMap(layouts.get("default"), "default layout"), images, pngBytes); + Map small = asMap(layouts.get("small"), "small layout"); + assertEqual("text", (String) small.get("t"), "small override type"); + assertEqual("Small", (String) small.get("text"), "small override text"); + Map lock = asMap(layouts.get("lockscreen"), "lockscreen layout"); + assertEqual("text", (String) lock.get("t"), "lockscreen override type"); + assertEqual("Lock", (String) lock.get("text"), "lockscreen override text"); + + // entries were added out of order and must come back sorted ascending by date, + // with state maps intact (string + number values) + List entries = asList(doc.get("entries"), "entries"); + assertEqual(2, entries.size(), "entry count"); + Map first = asMap(entries.get(0), "entry[0]"); + Map second = asMap(entries.get(1), "entry[1]"); + assertEqual(ENTRY_EARLY, asLong(first.get("date")), "entry[0] date (sorted)"); + assertEqual(ENTRY_LATE, asLong(second.get("date")), "entry[1] date (sorted)"); + Map firstState = asMap(first.get("state"), "entry[0] state"); + assertEqual("Packing", (String) firstState.get("status"), "entry[0] status"); + assertEqual(ENTRY_LATE, asLong(firstState.get("eta")), "entry[0] eta millis"); + assertBool(Math.abs(asDouble(firstState.get("progress")) - 0.25) < 0.0001, + "entry[0] progress fraction"); + Map secondState = asMap(second.get("state"), "entry[1] state"); + assertEqual("Delivered", (String) secondState.get("status"), "entry[1] status"); + + // the images list must name the registered blob, and the blob must be byte-exact + assertEqual(1, images.size(), "registered image blob count"); + String imageName = images.keySet().iterator().next(); + byte[] blob = images.get(imageName); + assertEqual(pngBytes.length, blob.length, "image blob length"); + for (int i = 0; i < pngBytes.length; i++) { + if (pngBytes[i] != blob[i]) { + fail("image blob byte " + i + " differs after registration"); + return false; + } + } + List imageNames = asList(doc.get("images"), "images list"); + assertEqual(1, imageNames.size(), "referenced image name count"); + assertEqual(imageName, (String) imageNames.get(0), "referenced image name"); + } catch (Throwable t) { + fail("Surfaces serializer round trip failed: " + t); + return false; + } + done(); + return true; + } + + private WidgetTimeline buildTimeline(byte[] pngBytes) { + SurfaceColumn root = new SurfaceColumn() + .setSpacing(4); + root.setPadding(2, 3, 4, 5); + root.setBackground(SurfaceColor.rgb(0xff112233, 0xff445566)); + root.setCornerRadius(8); + root.add(new SurfaceText("Order ${item}") + .setFontSize(16) + .setFontWeight(SurfaceFontWeight.SEMIBOLD) + .setColor(SurfaceColor.rgb(0xffff0000)) + .setMaxLines(2) + .setAlignment(SurfaceAlignment.LEADING)) + .add(new SurfaceDynamicText(SurfaceDynamicText.STYLE_TIMER_DOWN, "eta") + .setFontSize(20)) + .add(new SurfaceDynamicText(SurfaceDynamicText.STYLE_DATE, new Date(ENTRY_EARLY))) + .add(new SurfaceRow() + .setSpacing(2) + .add(new SurfaceProgress(SurfaceProgress.STYLE_LINEAR) + .setValueState("progress") + .setColor(SurfaceColor.ACCENT)) + .add(new SurfaceSpacer(6)) + .add(new SurfaceProgress(SurfaceProgress.STYLE_CIRCULAR) + .setValue(0.25f))) + .add(new SurfaceImage(EncodedImage.create(pngBytes)) + .setScaleMode(SurfaceImage.SCALE_FILL)) + .add(new SurfaceBox() + .add(new SurfaceText("Tap") + .setAlignment(SurfaceAlignment.BOTTOM_TRAILING)) + .setAction("open_box", actionParams())); + + Map earlyState = new HashMap(); + earlyState.put("status", "Packing"); + earlyState.put("eta", Long.valueOf(ENTRY_LATE)); + earlyState.put("progress", Double.valueOf(0.25)); + Map lateState = new HashMap(); + lateState.put("status", "Delivered"); + + return new WidgetTimeline() + .setContent(root) + .setContent(WidgetSize.SMALL, new SurfaceText("Small")) + .setContent(WidgetSize.LOCKSCREEN, new SurfaceText("Lock")) + // deliberately added out of order; the serializer must sort ascending + .addEntry(new Date(ENTRY_LATE), lateState) + .addEntry(new Date(ENTRY_EARLY), earlyState) + .setReloadPolicy(WidgetTimeline.RELOAD_NEVER); + } + + private Map actionParams() { + Map params = new HashMap(); + params.put("orderId", Integer.valueOf(42)); + params.put("label", "box"); + return params; + } + + private void checkDefaultLayout(Map root, Map images, + byte[] pngBytes) { + assertEqual("col", (String) root.get("t"), "root type"); + assertEqual(4L, asLong(root.get("spacing")), "root spacing"); + assertEqual(8L, asLong(root.get("corner")), "root corner radius"); + List pad = asList(root.get("pad"), "root pad"); + assertEqual(2L, asLong(pad.get(0)), "pad top"); + assertEqual(3L, asLong(pad.get(1)), "pad right"); + assertEqual(4L, asLong(pad.get(2)), "pad bottom"); + assertEqual(5L, asLong(pad.get(3)), "pad left"); + Map bg = asMap(root.get("bg"), "root bg"); + assertEqual(0xff112233L, asLong(bg.get("l")) & 0xffffffffL, "bg light argb"); + assertEqual(0xff445566L, asLong(bg.get("d")) & 0xffffffffL, "bg dark argb"); + + List children = asList(root.get("ch"), "root children"); + assertEqual(6, children.size(), "root child count"); + + // text node: placeholder text survives verbatim, styling keys present + Map text = asMap(children.get(0), "text node"); + assertEqual("text", (String) text.get("t"), "text type"); + assertEqual("Order ${item}", (String) text.get("text"), "text placeholder"); + assertEqual(16L, asLong(text.get("size")), "text size"); + assertEqual("semibold", (String) text.get("fw"), "text weight"); + assertEqual("leading", (String) text.get("align"), "text alignment"); + assertEqual(2L, asLong(text.get("maxLines")), "text maxLines"); + Map textColor = asMap(text.get("color"), "text color"); + assertEqual(0xffff0000L, asLong(textColor.get("l")) & 0xffffffffL, "text color light"); + + // dynamic text with a state date key + Map dynKey = asMap(children.get(1), "dyn dateKey node"); + assertEqual("dyn", (String) dynKey.get("t"), "dyn type"); + assertEqual("timerDown", (String) dynKey.get("style"), "dyn style"); + assertEqual("eta", (String) dynKey.get("dateKey"), "dyn dateKey"); + assertBool(dynKey.get("date") == null, "dateKey node has no fixed date"); + assertEqual(20L, asLong(dynKey.get("size")), "dyn size"); + + // dynamic text with a fixed date + Map dynDate = asMap(children.get(2), "dyn date node"); + assertEqual("date", (String) dynDate.get("style"), "dyn date style"); + assertEqual(ENTRY_EARLY, asLong(dynDate.get("date")), "dyn fixed date millis"); + assertBool(dynDate.get("dateKey") == null, "fixed-date node has no dateKey"); + + // row with the two progress variants and a spacer + Map row = asMap(children.get(3), "row node"); + assertEqual("row", (String) row.get("t"), "row type"); + assertEqual(2L, asLong(row.get("spacing")), "row spacing"); + List rowChildren = asList(row.get("ch"), "row children"); + assertEqual(3, rowChildren.size(), "row child count"); + Map linear = asMap(rowChildren.get(0), "linear progress"); + assertEqual("prog", (String) linear.get("t"), "linear progress type"); + assertEqual("linear", (String) linear.get("style"), "linear progress style"); + assertEqual("progress", (String) linear.get("valueKey"), "linear progress valueKey"); + Map progColor = asMap(linear.get("color"), "progress color"); + assertEqual("accent", (String) progColor.get("role"), "progress color role"); + Map spacer = asMap(rowChildren.get(1), "spacer"); + assertEqual("spacer", (String) spacer.get("t"), "spacer type"); + assertEqual(6L, asLong(spacer.get("min")), "spacer min"); + Map circular = asMap(rowChildren.get(2), "circular progress"); + assertEqual("circular", (String) circular.get("style"), "circular progress style"); + assertBool(Math.abs(asDouble(circular.get("value")) - 0.25) < 0.0001, + "circular progress value"); + + // image node references the blob registered by content hash + Map img = asMap(children.get(4), "image node"); + assertEqual("img", (String) img.get("t"), "image type"); + assertEqual("fill", (String) img.get("scale"), "image scale mode"); + String imageName = (String) img.get("name"); + assertBool(imageName != null && imageName.length() > 0, "image wire name present"); + assertBool(images.containsKey(imageName), "image blob registered under wire name"); + assertEqual(pngBytes.length, images.get(imageName).length, "image blob bytes"); + + // box with an action carrying sorted params + Map box = asMap(children.get(5), "box node"); + assertEqual("box", (String) box.get("t"), "box type"); + Map action = asMap(box.get("action"), "box action"); + assertEqual("open_box", (String) action.get("id"), "action id"); + Map params = asMap(action.get("p"), "action params"); + assertEqual(42L, asLong(params.get("orderId")), "action param number"); + assertEqual("box", (String) params.get("label"), "action param string"); + List boxChildren = asList(box.get("ch"), "box children"); + Map boxText = asMap(boxChildren.get(0), "box child"); + assertEqual("bottomTrailing", (String) boxText.get("align"), "box child alignment"); + } + + /// A tiny fake PNG payload: EncodedImage wraps bytes lazily and the serializer ships them + /// verbatim (content-hash named) without ever decoding, so any bytes exercise the path. + private static byte[] fakePngBytes() { + byte[] b = new byte[48]; + b[0] = (byte) 0x89; + b[1] = 'P'; + b[2] = 'N'; + b[3] = 'G'; + for (int i = 4; i < b.length; i++) { + b[i] = (byte) (i * 7); + } + return b; + } + + // --- JSON round-trip helpers: JSONParser yields Double or Long for numbers --- + + private static long asLong(Object o) { + if (o instanceof Number) { + return ((Number) o).longValue(); + } + throw new IllegalStateException("expected a number but got " + o); + } + + private static double asDouble(Object o) { + if (o instanceof Number) { + return ((Number) o).doubleValue(); + } + throw new IllegalStateException("expected a number but got " + o); + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object o, String what) { + if (o instanceof Map) { + return (Map) o; + } + throw new IllegalStateException(what + " is not a map: " + o); + } + + @SuppressWarnings("unchecked") + private static List asList(Object o, String what) { + if (o instanceof List) { + return (List) o; + } + throw new IllegalStateException(what + " is not a list: " + o); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesTimelineLogicTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesTimelineLogicTest.java new file mode 100644 index 00000000000..156a4188d57 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SurfacesTimelineLogicTest.java @@ -0,0 +1,133 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.io.JSONParser; +import com.codename1.surfaces.SurfaceRasterizer; +import com.codename1.surfaces.SurfaceSerializer; +import com.codename1.surfaces.SurfaceText; +import com.codename1.surfaces.WidgetSize; +import com.codename1.surfaces.WidgetTimeline; + +import java.io.StringReader; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/// External-surfaces timeline selection logic on the device VM: the desktop surface renderers +/// (JavaSE simulator, native Windows/Linux widget windows) pick the active entry, the next entry +/// flip and the per-size layout via SurfaceRasterizer's timeline helpers, operating on the parsed +/// wire document. Runs the same fixed timeline through serialize -> parse -> helpers and asserts +/// the selection rules at probe times around each entry. Assertion-only test, no screenshot. +public class SurfacesTimelineLogicTest extends BaseTest { + private static final long T1 = 1750000001000L; + private static final long T2 = 1750000002000L; + private static final long T3 = 1750000003000L; + + @Override + public boolean shouldTakeScreenshot() { + return false; + } + + @Override + public boolean runTest() { + try { + Map doc = new JSONParser().parseJSON( + new StringReader(buildTimelineJson())); + + // currentEntry: the entry whose date most recently passed; before every entry the + // FIRST entry is returned rather than null + assertEqual(T1, entryDate(SurfaceRasterizer.currentEntry(doc, T1 - 500)), + "before all entries the first entry is current"); + assertEqual(T1, entryDate(SurfaceRasterizer.currentEntry(doc, T1)), + "an entry becomes current exactly at its date"); + assertEqual(T1, entryDate(SurfaceRasterizer.currentEntry(doc, T2 - 1)), + "still the first entry just before the second"); + assertEqual(T2, entryDate(SurfaceRasterizer.currentEntry(doc, T2 + 500)), + "second entry current between second and third"); + assertEqual(T3, entryDate(SurfaceRasterizer.currentEntry(doc, T3 + 3600000L)), + "last entry stays current after the timeline ends"); + + // the current entry exposes its state map + Map midEntry = SurfaceRasterizer.currentEntry(doc, T2 + 500); + Map midState = castMap(midEntry.get("state")); + assertEqual("two", (String) midState.get("step"), "current entry state map"); + + // nextEntryFlip: the nearest future entry date, 0 when none is left + assertEqual(T1, SurfaceRasterizer.nextEntryFlip(doc, T1 - 500), + "first flip is the first entry"); + assertEqual(T2, SurfaceRasterizer.nextEntryFlip(doc, T1), + "at an entry date the flip is the NEXT entry"); + assertEqual(T3, SurfaceRasterizer.nextEntryFlip(doc, T2 + 1), + "flip after the second entry is the third"); + assertEqual(0L, SurfaceRasterizer.nextEntryFlip(doc, T3), + "no flip after the last entry"); + + // layoutForSize: explicit override wins, everything else falls back to default, + // and an unknown/null size name is the default too + Map smallLayout = SurfaceRasterizer.layoutForSize(doc, "small"); + assertEqual("Small", (String) smallLayout.get("text"), "small returns the override"); + Map mediumLayout = SurfaceRasterizer.layoutForSize(doc, "medium"); + assertEqual("Default", (String) mediumLayout.get("text"), + "medium falls back to default"); + Map largeLayout = SurfaceRasterizer.layoutForSize(doc, "large"); + assertEqual("Default", (String) largeLayout.get("text"), + "large falls back to default"); + Map nullLayout = SurfaceRasterizer.layoutForSize(doc, null); + assertEqual("Default", (String) nullLayout.get("text"), + "null size name falls back to default"); + Map bogusLayout = SurfaceRasterizer.layoutForSize(doc, "bogus"); + assertEqual("Default", (String) bogusLayout.get("text"), + "unknown size name falls back to default"); + assertBool(SurfaceRasterizer.layoutForSize(null, "small") == null, + "null document yields null layout"); + + // degenerate documents + Map empty = new HashMap(); + assertBool(SurfaceRasterizer.currentEntry(empty, T1) == null, + "entry-less document has no current entry"); + assertEqual(0L, SurfaceRasterizer.nextEntryFlip(empty, T1), + "entry-less document never flips"); + } catch (Throwable t) { + fail("Surfaces timeline logic failed: " + t); + return false; + } + done(); + return true; + } + + private String buildTimelineJson() { + Map s1 = new HashMap(); + s1.put("step", "one"); + Map s2 = new HashMap(); + s2.put("step", "two"); + Map s3 = new HashMap(); + s3.put("step", "three"); + WidgetTimeline timeline = new WidgetTimeline() + .setContent(new SurfaceText("Default")) + .setContent(WidgetSize.SMALL, new SurfaceText("Small")) + .addEntry(new Date(T1), s1) + .addEntry(new Date(T2), s2) + .addEntry(new Date(T3), s3); + return SurfaceSerializer.serializeTimeline("cn1ss_status", timeline, + new LinkedHashMap()); + } + + private static long entryDate(Map entry) { + if (entry == null) { + throw new IllegalStateException("expected a timeline entry but got null"); + } + Object date = entry.get("date"); + if (date instanceof Number) { + return ((Number) date).longValue(); + } + throw new IllegalStateException("entry date is not a number: " + date); + } + + @SuppressWarnings("unchecked") + private static Map castMap(Object o) { + if (o instanceof Map) { + return (Map) o; + } + throw new IllegalStateException("expected a map but got " + o); + } +} diff --git a/scripts/hellocodenameone/common/src/main/resources/surfaces.json b/scripts/hellocodenameone/common/src/main/resources/surfaces.json new file mode 100644 index 00000000000..48792f09547 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/resources/surfaces.json @@ -0,0 +1,14 @@ +{ + "_comment": "Build-time widget kinds manifest for the cn1ss device suite. Referencing com.codename1.surfaces from the suite (Surfaces* tests) makes the iOS/Android builders require this manifest: widget kinds are compiled into the native widget gallery, so they cannot be registered at runtime only. The id mirrors the runtime Surfaces.registerWidgetKind call in SurfacesPublishTest. liveActivities keeps the ActivityKit lowering (CN1LiveActivityWidget.swift, NSSupportsLiveActivities, the Android ongoing-notification manager) compiled and exercised by every platform CI leg.", + "liveActivities": true, + "kinds": [ + { + "id": "cn1ss_status", + "name": "CN1SS Status", + "description": "Surfaces suite status widget", + "iosFamilies": ["small", "medium"], + "androidMinWidthDp": 180, + "androidMinHeightDp": 60 + } + ] +} diff --git a/scripts/hellocodenameone/ios/src/main/objectivec/com_codenameone_examples_hellocodenameone_SurfacesRemoteViewsNativeImpl.h b/scripts/hellocodenameone/ios/src/main/objectivec/com_codenameone_examples_hellocodenameone_SurfacesRemoteViewsNativeImpl.h new file mode 100644 index 00000000000..850e9146f5f --- /dev/null +++ b/scripts/hellocodenameone/ios/src/main/objectivec/com_codenameone_examples_hellocodenameone_SurfacesRemoteViewsNativeImpl.h @@ -0,0 +1,9 @@ +#import + +@interface com_codenameone_examples_hellocodenameone_SurfacesRemoteViewsNativeImpl : NSObject { +} + +-(void*)createWidgetView:(NSString*)param param1:(int)param1 param2:(int)param2; +-(NSString*)probeTimerRender:(NSString*)param; +-(BOOL)isSupported; +@end diff --git a/scripts/hellocodenameone/ios/src/main/objectivec/com_codenameone_examples_hellocodenameone_SurfacesRemoteViewsNativeImpl.m b/scripts/hellocodenameone/ios/src/main/objectivec/com_codenameone_examples_hellocodenameone_SurfacesRemoteViewsNativeImpl.m new file mode 100644 index 00000000000..c4b4ab24867 --- /dev/null +++ b/scripts/hellocodenameone/ios/src/main/objectivec/com_codenameone_examples_hellocodenameone_SurfacesRemoteViewsNativeImpl.m @@ -0,0 +1,19 @@ +#import "com_codenameone_examples_hellocodenameone_SurfacesRemoteViewsNativeImpl.h" + +// Inert stub: the RemoteViews lowering under test is Android-only, and the suite test gates +// on the Android platform name before touching the native interface. +@implementation com_codenameone_examples_hellocodenameone_SurfacesRemoteViewsNativeImpl + +-(void*)createWidgetView:(NSString*)param param1:(int)param1 param2:(int)param2 { + return nil; +} + +-(NSString*)probeTimerRender:(NSString*)param { + return nil; +} + +-(BOOL)isSupported { + return NO; +} + +@end diff --git a/scripts/ios/screenshots-metal/SurfacesRasterizer.png b/scripts/ios/screenshots-metal/SurfacesRasterizer.png new file mode 100644 index 00000000000..dc0d57ab49c Binary files /dev/null and b/scripts/ios/screenshots-metal/SurfacesRasterizer.png differ diff --git a/scripts/ios/screenshots/SurfacesRasterizer.png b/scripts/ios/screenshots/SurfacesRasterizer.png new file mode 100644 index 00000000000..fab24a9cbb4 Binary files /dev/null and b/scripts/ios/screenshots/SurfacesRasterizer.png differ diff --git a/scripts/javascript/screenshots/SurfacesRasterizer.png b/scripts/javascript/screenshots/SurfacesRasterizer.png new file mode 100644 index 00000000000..030481b8c4c Binary files /dev/null and b/scripts/javascript/screenshots/SurfacesRasterizer.png differ diff --git a/scripts/linux/screenshots-arm/SurfacesRasterizer.png b/scripts/linux/screenshots-arm/SurfacesRasterizer.png new file mode 100644 index 00000000000..e40cc72a28a Binary files /dev/null and b/scripts/linux/screenshots-arm/SurfacesRasterizer.png differ diff --git a/scripts/linux/screenshots/SurfacesRasterizer.png b/scripts/linux/screenshots/SurfacesRasterizer.png new file mode 100644 index 00000000000..e40cc72a28a Binary files /dev/null and b/scripts/linux/screenshots/SurfacesRasterizer.png differ diff --git a/scripts/mac-native/screenshots/SurfacesRasterizer.png b/scripts/mac-native/screenshots/SurfacesRasterizer.png new file mode 100644 index 00000000000..e3204ad65fe Binary files /dev/null and b/scripts/mac-native/screenshots/SurfacesRasterizer.png differ diff --git a/scripts/windows/screenshots/SurfacesRasterizer.png b/scripts/windows/screenshots/SurfacesRasterizer.png new file mode 100644 index 00000000000..5674671b473 Binary files /dev/null and b/scripts/windows/screenshots/SurfacesRasterizer.png differ diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index e3b7ae15bb2..6d632cdddb0 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -835,7 +835,13 @@ private static void writeCmakeProject(File projectRoot, File srcRoot, String app // supported by clang/clang-cl, gcc and Xcode's clang alike. writer.append("set(CMAKE_C_STANDARD 11)\n"); if (windows) { - writer.append("set(CMAKE_CXX_STANDARD 17)\n"); + // Overridable from the configure line (-DCMAKE_CXX_STANDARD=20): the + // Widgets Board provider (windows.msix=true) needs C++20 because + // C++/WinRT at C++17 falls back to , which the + // modern MSVC STL rejects under clang-cl. + writer.append("if(NOT DEFINED CMAKE_CXX_STANDARD)\n"); + writer.append(" set(CMAKE_CXX_STANDARD 17)\n"); + writer.append("endif()\n"); } writer.append("set(CN1_APP_SOURCE_ROOT \"") .append(escapeCmakePath(srcRootPath)) diff --git a/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js b/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js index 5e5849e25f5..1d74e28d1c2 100644 --- a/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js +++ b/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js @@ -5254,7 +5254,17 @@ bindNative(["cn1_java_lang_Double_toStringImpl_double_boolean_R_java_lang_String bindNative(["cn1_java_lang_StringBuilder_append_char_R_java_lang_StringBuilder"], function(__cn1ThisObject, ch) { return sbAppendNativeString(__cn1ThisObject, String.fromCharCode(ch | 0)); }); bindNative(["cn1_java_lang_StringBuilder_append_int_R_java_lang_StringBuilder"], function(__cn1ThisObject, value) { return sbAppendNativeString(__cn1ThisObject, String(value | 0)); }); bindNative(["cn1_java_lang_StringBuilder_append_long_R_java_lang_StringBuilder"], function(__cn1ThisObject, value) { return sbAppendNativeString(__cn1ThisObject, _LtoStr(value)); }); -bindNative(["cn1_java_lang_StringBuilder_append_java_lang_Object_R_java_lang_StringBuilder"], function(__cn1ThisObject, obj) { return sbAppendNativeString(__cn1ThisObject, jvm.toNativeString(obj)); }); +bindNative(["cn1_java_lang_StringBuilder_append_java_lang_Object_R_java_lang_StringBuilder"], function*(__cn1ThisObject, obj) { + // Fast path: strings (and null) convert synchronously. Everything else must + // dispatch the Java toString() virtually -- jvm.toNativeString's "" + value + // fallback stringifies heap objects (boxed Integer/Long/Double included) as + // the JS "[object Object]", which corrupted JSONWriter output (the + // surfacesJsonRoundTrip bug). + if (obj == null || typeof obj === "string" || obj.__nativeString != null || obj.__class === "java_lang_String") { + return sbAppendNativeString(__cn1ThisObject, jvm.toNativeString(obj)); + } + return sbAppendNativeString(__cn1ThisObject, yield* runtimeToNativeString(obj)); +}); bindNative(["cn1_java_lang_StringBuilder_append_java_lang_String_R_java_lang_StringBuilder"], function(__cn1ThisObject, str) { return sbAppendNativeString(__cn1ThisObject, jvm.toNativeString(str)); }); bindNative(["cn1_java_lang_StringBuilder_charAt_int_R_char"], function(__cn1ThisObject, index) { index = index | 0;