From 4b5075bceb302e7a8acbd559bc1ac4695f798672 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:18:30 +0700 Subject: [PATCH 01/17] Prompt for POST_NOTIFICATIONS so Android live activities can start (discussion #5444) On Android 13+ the ongoing notification a live activity lowers to needs the POST_NOTIFICATIONS runtime permission. The build declares it in the manifest whenever surfaces.json sets "liveActivities": true, but nothing ever asked the user for it: the only runtime requests in the port are on the push path, the local-notification path and the explicit requestNotificationPermission API, none of which run for a surfaces-only app, and the default targetSdkVersion is well past 32 so there is no legacy auto-prompt either. The result on a fresh install was a silent dead end. areNotificationsEnabled() reads false while the permission is ungranted, so isLiveActivitySupported() returned false, LiveActivity.start() bailed to an inert handle before ever reaching the bridge, and update()/end() were no-ops. No exception, no log. The simulator can't catch it -- notifications are enabled there, so SurfaceDiagnostics.inertActivity never fires. The developer guide meanwhile promised "Android 13 and newer prompts for the notification permission, which the build declares for you", which was simply untrue. isSupported() stops conflating "the user turned notifications off" with "nobody has asked yet". From API 33, when areNotificationsEnabled() is false it now checks why: granted-but-disabled is a settled choice and stays unsupported, merely-ungranted is a pending prompt and counts as supported. That distinction is what makes the fix reachable at all -- reporting false there would make the core skip the very call that prompts. start() then raises the prompt through the existing checkForPermission(..., forceAsk=true) path, the same one requestNotificationPermission uses, which blocks the caller while keeping the EDT pumping and skips the CN1 rationale dialog (so it is safe off the EDT, where the guide tells apps to publish from). update() and end() never prompt: they act on an activity that was already granted at start. Three refusals are now distinguished and logged to CN1Surfaces instead of failing silently -- no foreground activity to prompt from (the refusal flag is left alone so the next foreground start still asks), POST_NOTIFICATIONS absent from the manifest because surfaces.json lacks "liveActivities": true (an undeclared permission is auto-denied with no UI, which would otherwise be remembered as a user refusal), and an actual decline. A decline is persisted in the surface store so isSupported() reports false from then on rather than nagging on every start; it can't trap an app, because enabling notifications later in the system settings makes areNotificationsEnabled() true and the flag is never consulted. The guide sentence is now true and gained the specifics, as did the Android per-platform notes and the LiveActivity.start/isSupported javadoc. android module compiles with SpotBugs at zero findings; core-unittests Surface* 51/51. Not exercised on hardware -- the port has no unit-test harness -- so the grant/decline/background-start paths still want a manual pass on an Android 13+ device. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/surfaces/LiveActivity.java | 10 +- .../surfaces/CN1LiveActivityManager.java | 107 +++++++++++++++++- .../android/surfaces/CN1SurfaceStore.java | 22 ++++ .../External-Surfaces.asciidoc | 4 +- 4 files changed, 136 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/surfaces/LiveActivity.java b/CodenameOne/src/com/codename1/surfaces/LiveActivity.java index 844f57f2138..4fb5e781362 100644 --- a/CodenameOne/src/com/codename1/surfaces/LiveActivity.java +++ b/CodenameOne/src/com/codename1/surfaces/LiveActivity.java @@ -51,7 +51,9 @@ private LiveActivity(String id) { this.active = id != null; } - /// Returns true when this platform can present live activities. + /// Returns true when this platform can present live activities, including when doing so still + /// depends on a permission the user has not been asked for yet (Android 13+ raises that prompt + /// from [#start(LiveActivityDescriptor, Map)]). It turns false once the user refuses. /// /// #### Returns /// @@ -64,6 +66,12 @@ public static boolean isSupported() { /// 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. /// + /// On Android 13 and newer the ongoing notification a live activity lowers to needs the + /// `POST_NOTIFICATIONS` permission, so the first start on a fresh install raises the system + /// prompt and blocks until the user answers. Start that first activity with your app in the + /// foreground: a background service or push handler has no UI to prompt from and the start is + /// refused. A decline is remembered -- [#isSupported()] reports false from then on. + /// /// #### Threading /// /// Callable from any thread, and a background thread is the right one. Starting an activity diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java index 777038b5154..4959b3fea93 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -26,10 +26,14 @@ import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; +import android.content.pm.PackageManager; import android.os.Build; import android.util.Log; import android.widget.RemoteViews; +import com.codename1.impl.android.AndroidImplementation; +import com.codename1.impl.android.AndroidNativeUtil; + import org.json.JSONArray; import org.json.JSONObject; @@ -44,7 +48,11 @@ /// /// 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 +/// below that and when the user disabled notifications. On Android 13 (API 33) and newer an +/// ongoing notification additionally needs the `POST_NOTIFICATIONS` runtime permission, which the +/// build declares for you: [#start(Context, String, Map)] raises the system prompt the first time +/// an app starts a live activity without it, and a refusal is remembered so `isSupported` reports +/// false from then on rather than re-prompting on every start. 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 { @@ -55,7 +63,10 @@ public final class CN1LiveActivityManager { private CN1LiveActivityManager() { } - /// Returns true when live activities can be presented on this device right now. + /// Returns true when live activities can be presented on this device, either right now or + /// after the `POST_NOTIFICATIONS` prompt `start` raises on Android 13+. A pending permission + /// counts as supported: reporting false there would make the app skip the very call that + /// prompts, so a first-run install could never present an activity at all. public static boolean isSupported(Context ctx) { if (ctx == null || Build.VERSION.SDK_INT < 24) { return false; @@ -63,15 +74,31 @@ public static boolean isSupported(Context ctx) { try { NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); - return nm != null && nm.areNotificationsEnabled(); + if (nm == null) { + return false; + } + if (nm.areNotificationsEnabled()) { + return true; + } + if (Build.VERSION.SDK_INT < 33) { + // disabled notifications are a settled user choice, not a pending prompt + return false; + } + // From API 33 notifications also read as disabled while POST_NOTIFICATIONS is merely + // ungranted, which is the state of every fresh install. Granted-but-disabled is the + // settled choice again; ungranted is still requestable, hence still supported. + return !hasPostNotificationsPermission(ctx) + && !CN1SurfaceStore.isNotificationPermissionRefused(ctx); } catch (Throwable t) { return false; } } /// Starts a live activity from a serialized descriptor; returns its id or null on failure. + /// Raises the `POST_NOTIFICATIONS` prompt first when Android 13+ needs it, blocking the + /// calling thread until the user answers. public static String start(Context ctx, String descriptorJson, Map images) { - if (!isSupported(ctx)) { + if (!isSupported(ctx) || !ensureNotificationPermission(ctx)) { return null; } try { @@ -128,6 +155,78 @@ public static void end(Context ctx, String activityId, String finalStateJson, } } + // --- notification permission ---------------------------------------------- + + /// Makes sure the ongoing notification a live activity lowers to can actually be posted: + /// on Android 13+ that needs the `POST_NOTIFICATIONS` runtime permission the build declares + /// but nobody has granted yet on a fresh install. Raises the standard Codename One permission + /// request (which blocks the calling thread until the user answers, in a way that keeps the + /// EDT pumping when called from it) and remembers a refusal so the next `start` reports + /// unsupported instead of prompting again. + /// + /// Only `start` calls this. `update` and `end` act on an activity that is already running, + /// so the permission was necessarily granted when it started. + private static boolean ensureNotificationPermission(Context ctx) { + if (Build.VERSION.SDK_INT < 33 || ctx == null || hasPostNotificationsPermission(ctx)) { + return true; + } + if (CN1SurfaceStore.isNotificationPermissionRefused(ctx)) { + return false; + } + if (AndroidNativeUtil.getActivity() == null) { + // nothing to prompt from -- a live activity started from a background service or a + // push. Leave the refusal flag alone so the next start with UI in front still asks. + Log.w(TAG, "Cannot start a live activity: POST_NOTIFICATIONS has not been granted " + + "and there is no activity in the foreground to request it from."); + return false; + } + if (!isPermissionDeclared()) { + // requesting an undeclared permission is auto-denied without any UI, which would + // otherwise look like a refusal and be remembered as one + Log.e(TAG, "Cannot start a live activity: POST_NOTIFICATIONS is missing from the " + + "manifest. The build declares it for apps whose surfaces.json sets " + + "\"liveActivities\": true -- add that and rebuild."); + return false; + } + boolean granted; + try { + granted = AndroidImplementation.checkForPermission( + "android.permission.POST_NOTIFICATIONS", + "This is required to show live activities", true); + } catch (Throwable t) { + Log.w(TAG, "Failed to request the POST_NOTIFICATIONS permission", t); + return false; + } + CN1SurfaceStore.setNotificationPermissionRefused(ctx, !granted); + if (!granted) { + Log.w(TAG, "Live activities are unavailable: the user declined the POST_NOTIFICATIONS " + + "permission. LiveActivity.isSupported() reports false from now on; the user " + + "can re-enable notifications in the system settings."); + } + return granted; + } + + /// True when `POST_NOTIFICATIONS` is in the merged manifest. A failed or empty lookup reads + /// as "declared" so a broken query never blocks the prompt. + private static boolean isPermissionDeclared() { + try { + java.util.List declared = AndroidImplementation.getRequestedPermissions(); + return declared.isEmpty() + || declared.contains("android.permission.POST_NOTIFICATIONS"); + } catch (Throwable t) { + return true; + } + } + + private static boolean hasPostNotificationsPermission(Context ctx) { + try { + return ctx.getPackageManager().checkPermission("android.permission.POST_NOTIFICATIONS", + ctx.getPackageName()) == PackageManager.PERMISSION_GRANTED; + } catch (Throwable t) { + return false; + } + } + // --- internals ------------------------------------------------------------ private static JSONObject replaceState(Context ctx, String activityId, String stateJson) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java index 20b5e41b23a..db3e7306cc6 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java @@ -56,6 +56,7 @@ public final class CN1SurfaceStore { 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 static final String KEY_NOTIFICATIONS_REFUSED = "notificationsRefused"; private CN1SurfaceStore() { } @@ -223,6 +224,27 @@ public static boolean tryClaimBackgroundFetch(Context ctx, String kindId, long n return true; } + // --- live activity notification permission -------------------------------- + + /// True once the user turned down the `POST_NOTIFICATIONS` prompt raised by the first + /// `LiveActivity.start()` on Android 13+. `CN1LiveActivityManager` consults this so a refusal + /// makes `isLiveActivitySupported()` report false (the honest answer) instead of re-prompting + /// on every start. Granting notifications later in the system settings makes + /// `areNotificationsEnabled()` true again, which is checked first, so the flag never traps an + /// app that the user changed their mind about. + public static boolean isNotificationPermissionRefused(Context ctx) { + return prefs(ctx).getBoolean(KEY_NOTIFICATIONS_REFUSED, false); + } + + /// Records the outcome of the `POST_NOTIFICATIONS` prompt; see + /// [#isNotificationPermissionRefused(Context)]. + public static void setNotificationPermissionRefused(Context ctx, boolean refused) { + SharedPreferences prefs = prefs(ctx); + if (prefs.getBoolean(KEY_NOTIFICATIONS_REFUSED, false) != refused) { + prefs.edit().putBoolean(KEY_NOTIFICATIONS_REFUSED, refused).apply(); + } + } + // --- internals ------------------------------------------------------------ private static File baseDir(Context ctx) { diff --git a/docs/developer-guide/External-Surfaces.asciidoc b/docs/developer-guide/External-Surfaces.asciidoc index 17f122e5564..cb88eb437cd 100644 --- a/docs/developer-guide/External-Surfaces.asciidoc +++ b/docs/developer-guide/External-Surfaces.asciidoc @@ -123,7 +123,7 @@ The simulator preview renders the running activity as a mock Dynamic Island pill 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. +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; on Android 13 and newer the first `start(...)` prompts for the notification permission, which the build declares for you, and `isSupported()` reports false from then on if the user declines. On desktop it appears in the simulator preview or as a floating window. === Actions and cold start @@ -177,7 +177,7 @@ Widget taps deep link back into the app through the `cn1surface://` URL scheme, ==== 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. +Widgets are rendered through `RemoteViews` by generated per-kind providers; no Android-specific build hints are needed, and the per-kind sizing metadata comes from `surfaces.json`. Timeline entry flips are scheduled with inexact alarms (a 30-second window) to avoid the exact-alarm permission by default; apps that need to-the-second flips can opt in with the `android.surfaces.exactAlarms` build hint. Second-precision countdowns still tick natively through `Chronometer`. Live activities lower to ongoing notifications, which on Android 13 and newer require the `POST_NOTIFICATIONS` runtime permission: the build declares it for you when `surfaces.json` sets `"liveActivities": true`, and the first `LiveActivity.start(...)` raises the system prompt, blocking the calling thread until the user answers. Start the first activity while your app is in the foreground -- there is no UI to prompt from in a background service or push handler, so a start from one before the permission is granted is refused (`adb logcat -s CN1Surfaces` says which case you hit). 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 From 2717c613562b8ba27690721f07aa619fea20e162 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:32:06 +0700 Subject: [PATCH 02/17] Address review: bound the prompt, check foreground, read the manifest precisely Three review findings, all correct. Codex P1: AndroidNativeUtil.getActivity() stays non-null after onStop, so a null check does not prove there is UI to prompt from -- a background fetch or push handler running with the app stopped would have tried to raise the dialog anyway. Check CodenameOneActivity.isBackground() instead. (The comment's other scenario, getActivity() returning a CodenameOneBackgroundFetchActivity, cannot happen: setActivity is typed to CodenameOneActivity. The instanceof still earns its keep by making the cast total.) Codex P2: a dismissed dialog was latched as a permanent refusal. The platform hands back one bare boolean for an explicit "Don't allow", a dialog swiped away without choosing, and a request auto-denied with no UI at all, so the first false cannot be read as a decision. Count prompts instead of latching a flag, bounded at two -- Android auto-denies after two refusals, so a third attempt would never reach the user. Only attempts that actually reached the user count: no foreground activity and a missing manifest entry both return without spending one. Codex P2: denials made outside surfaces (push registration, Display.requestNotificationPermission, local notifications) left the old surfaces-private flag unset, so isSupported() claimed true. Nothing is now consulted before the live permission state, so a grant from any of those paths takes effect immediately and clears the count, and a denial from them costs at most the two bounded attempts -- which show no UI once the system is auto-denying -- before isSupported() settles on false. Copilot: isPermissionDeclared() could not tell "no permissions declared" from "lookup failed" because getRequestedPermissions() flattens both into an empty list, and it can throw NPE of its own on a null requestedPermissions array. Read PackageInfo directly instead and answer the narrower question the caller actually has -- isPermissionMissing() is true only when the manifest was read and POST_NOTIFICATIONS is definitely absent, so an unreadable manifest proceeds to the prompt rather than being mistaken for a broken build. SpotBugs rejected the first cut of that helper (NP_BOOLEAN_RETURN_NULL on a three-state Boolean); the boolean phrasing above is both cleaner and clean under the gate. Docs follow: the guide, the Android per-platform notes and the LiveActivity javadoc now describe two bounded attempts rather than a single latched refusal. android module compiles with SpotBugs at zero findings; core-unittests Surface* 51/51. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/surfaces/LiveActivity.java | 6 +- .../surfaces/CN1LiveActivityManager.java | 120 +++++++++++++----- .../android/surfaces/CN1SurfaceStore.java | 35 +++-- .../External-Surfaces.asciidoc | 4 +- 4 files changed, 114 insertions(+), 51 deletions(-) diff --git a/CodenameOne/src/com/codename1/surfaces/LiveActivity.java b/CodenameOne/src/com/codename1/surfaces/LiveActivity.java index 4fb5e781362..0e2e7d4464d 100644 --- a/CodenameOne/src/com/codename1/surfaces/LiveActivity.java +++ b/CodenameOne/src/com/codename1/surfaces/LiveActivity.java @@ -53,7 +53,8 @@ private LiveActivity(String id) { /// Returns true when this platform can present live activities, including when doing so still /// depends on a permission the user has not been asked for yet (Android 13+ raises that prompt - /// from [#start(LiveActivityDescriptor, Map)]). It turns false once the user refuses. + /// from [#start(LiveActivityDescriptor, Map)]). It turns false once the user has refused that + /// prompt as often as `start` will raise it, or has switched notifications off for the app. /// /// #### Returns /// @@ -70,7 +71,8 @@ public static boolean isSupported() { /// `POST_NOTIFICATIONS` permission, so the first start on a fresh install raises the system /// prompt and blocks until the user answers. Start that first activity with your app in the /// foreground: a background service or push handler has no UI to prompt from and the start is - /// refused. A decline is remembered -- [#isSupported()] reports false from then on. + /// refused. The prompt is raised at most twice across an install, after which [#isSupported()] + /// reports false. /// /// #### Threading /// diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java index 4959b3fea93..9f463db7fde 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -33,6 +33,7 @@ import com.codename1.impl.android.AndroidImplementation; import com.codename1.impl.android.AndroidNativeUtil; +import com.codename1.impl.android.CodenameOneActivity; import org.json.JSONArray; import org.json.JSONObject; @@ -51,14 +52,16 @@ /// below that and when the user disabled notifications. On Android 13 (API 33) and newer an /// ongoing notification additionally needs the `POST_NOTIFICATIONS` runtime permission, which the /// build declares for you: [#start(Context, String, Map)] raises the system prompt the first time -/// an app starts a live activity without it, and a refusal is remembered so `isSupported` reports -/// false from then on rather than re-prompting on every start. 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. +/// an app starts a live activity without it, at most twice across the install before `isSupported` +/// reports false. 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"; + /// Prompt attempts before live activities report unsupported; Android's own model auto-denies + /// after two refusals, so a third attempt would never reach the user anyway. + private static final int MAX_NOTIFICATION_PROMPTS = 2; private CN1LiveActivityManager() { } @@ -66,7 +69,9 @@ private CN1LiveActivityManager() { /// Returns true when live activities can be presented on this device, either right now or /// after the `POST_NOTIFICATIONS` prompt `start` raises on Android 13+. A pending permission /// counts as supported: reporting false there would make the app skip the very call that - /// prompts, so a first-run install could never present an activity at all. + /// prompts, so a first-run install could never present an activity at all. It goes false once + /// the permission is held but notifications are switched off, or the prompt has been refused + /// as often as `start` will raise it. public static boolean isSupported(Context ctx) { if (ctx == null || Build.VERSION.SDK_INT < 24) { return false; @@ -86,9 +91,8 @@ public static boolean isSupported(Context ctx) { } // From API 33 notifications also read as disabled while POST_NOTIFICATIONS is merely // ungranted, which is the state of every fresh install. Granted-but-disabled is the - // settled choice again; ungranted is still requestable, hence still supported. - return !hasPostNotificationsPermission(ctx) - && !CN1SurfaceStore.isNotificationPermissionRefused(ctx); + // settled choice again; ungranted is supported while a prompt attempt remains. + return !hasPostNotificationsPermission(ctx) && canPromptAgain(ctx); } catch (Throwable t) { return false; } @@ -160,34 +164,52 @@ public static void end(Context ctx, String activityId, String finalStateJson, /// Makes sure the ongoing notification a live activity lowers to can actually be posted: /// on Android 13+ that needs the `POST_NOTIFICATIONS` runtime permission the build declares /// but nobody has granted yet on a fresh install. Raises the standard Codename One permission - /// request (which blocks the calling thread until the user answers, in a way that keeps the - /// EDT pumping when called from it) and remembers a refusal so the next `start` reports - /// unsupported instead of prompting again. + /// request, which blocks the calling thread until the user answers in a way that keeps the + /// EDT pumping when called from it. + /// + /// A request that comes back ungranted is counted rather than latched: the platform hands + /// back one bare boolean for an explicit "Don't allow", a dialog the user dismissed without + /// choosing and a request the system auto-denied without showing anything at all, so treating + /// the first false as a permanent refusal would strand a user who only swiped the dialog + /// away. Two attempts, matching Android's own two-strike model, then `isSupported` reports + /// false. Nothing is consulted before the live permission state, so a grant that arrives from + /// anywhere -- these prompts, push registration, `Display.requestNotificationPermission`, the + /// system settings -- takes effect immediately and resets the count. /// /// Only `start` calls this. `update` and `end` act on an activity that is already running, /// so the permission was necessarily granted when it started. private static boolean ensureNotificationPermission(Context ctx) { - if (Build.VERSION.SDK_INT < 33 || ctx == null || hasPostNotificationsPermission(ctx)) { + if (Build.VERSION.SDK_INT < 33 || ctx == null) { + return true; + } + if (hasPostNotificationsPermission(ctx)) { + CN1SurfaceStore.clearNotificationPrompts(ctx); return true; } - if (CN1SurfaceStore.isNotificationPermissionRefused(ctx)) { + if (!canPromptAgain(ctx)) { + Log.w(TAG, "Live activities are unavailable: POST_NOTIFICATIONS was refused twice. " + + "LiveActivity.isSupported() reports false until the user enables " + + "notifications for this app in the system settings."); return false; } - if (AndroidNativeUtil.getActivity() == null) { - // nothing to prompt from -- a live activity started from a background service or a - // push. Leave the refusal flag alone so the next start with UI in front still asks. + if (!hasForegroundActivity()) { + // nothing to prompt from -- a live activity started from a background service, a + // push, or with the app stopped. Not counted as an attempt, so the next start with + // the app in front still asks. Log.w(TAG, "Cannot start a live activity: POST_NOTIFICATIONS has not been granted " - + "and there is no activity in the foreground to request it from."); + + "and the app is not in the foreground to request it. Start the first live " + + "activity while the app is visible."); return false; } - if (!isPermissionDeclared()) { - // requesting an undeclared permission is auto-denied without any UI, which would - // otherwise look like a refusal and be remembered as one + if (isPermissionMissing(ctx)) { + // requesting an undeclared permission is auto-denied without any UI; not counted as + // an attempt either, so fixing the manifest is all it takes Log.e(TAG, "Cannot start a live activity: POST_NOTIFICATIONS is missing from the " + "manifest. The build declares it for apps whose surfaces.json sets " + "\"liveActivities\": true -- add that and rebuild."); return false; } + CN1SurfaceStore.recordNotificationPrompt(ctx); boolean granted; try { granted = AndroidImplementation.checkForPermission( @@ -197,24 +219,56 @@ private static boolean ensureNotificationPermission(Context ctx) { Log.w(TAG, "Failed to request the POST_NOTIFICATIONS permission", t); return false; } - CN1SurfaceStore.setNotificationPermissionRefused(ctx, !granted); - if (!granted) { - Log.w(TAG, "Live activities are unavailable: the user declined the POST_NOTIFICATIONS " - + "permission. LiveActivity.isSupported() reports false from now on; the user " - + "can re-enable notifications in the system settings."); + if (granted) { + CN1SurfaceStore.clearNotificationPrompts(ctx); + return true; } - return granted; + Log.w(TAG, "Live activities are unavailable for now: POST_NOTIFICATIONS was not granted " + + "(attempt " + CN1SurfaceStore.getNotificationPromptCount(ctx) + " of " + + MAX_NOTIFICATION_PROMPTS + ")."); + return false; } - /// True when `POST_NOTIFICATIONS` is in the merged manifest. A failed or empty lookup reads - /// as "declared" so a broken query never blocks the prompt. - private static boolean isPermissionDeclared() { + /// True while a prompt attempt remains; see + /// `CN1SurfaceStore#getNotificationPromptCount(Context)`. + private static boolean canPromptAgain(Context ctx) { + return CN1SurfaceStore.getNotificationPromptCount(ctx) < MAX_NOTIFICATION_PROMPTS; + } + + /// True when the app has a visible activity to raise the system dialog from. The activity + /// reference outlives `onStop`, so a non-null one proves nothing on its own -- a background + /// fetch or a push handler running with the app stopped still sees it. + private static boolean hasForegroundActivity() { + android.app.Activity a = AndroidNativeUtil.getActivity(); + return a instanceof CodenameOneActivity && !((CodenameOneActivity) a).isBackground(); + } + + /// True only when the manifest was read successfully and `POST_NOTIFICATIONS` is definitely + /// absent from it. "Definitely" is the point: a package that declares no permissions at all + /// yields an empty or null array, which is a real answer and not a failed lookup, while a + /// manifest that could not be read at all reports false so an unknown never blocks the prompt. + /// Reading `PackageInfo` here rather than through + /// `AndroidImplementation#getRequestedPermissions()` is what keeps those apart -- that helper + /// flattens both a missing package and an unreadable one into the same empty list. + private static boolean isPermissionMissing(Context ctx) { try { - java.util.List declared = AndroidImplementation.getRequestedPermissions(); - return declared.isEmpty() - || declared.contains("android.permission.POST_NOTIFICATIONS"); - } catch (Throwable t) { + android.content.pm.PackageInfo info = ctx.getPackageManager().getPackageInfo( + ctx.getPackageName(), PackageManager.GET_PERMISSIONS); + if (info == null) { + return false; + } + String[] declared = info.requestedPermissions; + if (declared == null) { + return true; + } + for (String p : declared) { + if ("android.permission.POST_NOTIFICATIONS".equals(p)) { + return false; + } + } return true; + } catch (Throwable t) { + return false; } } diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java index db3e7306cc6..2e232a0bd24 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java @@ -56,7 +56,7 @@ public final class CN1SurfaceStore { 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 static final String KEY_NOTIFICATIONS_REFUSED = "notificationsRefused"; + private static final String KEY_NOTIFICATION_PROMPTS = "notificationPrompts"; private CN1SurfaceStore() { } @@ -226,22 +226,29 @@ public static boolean tryClaimBackgroundFetch(Context ctx, String kindId, long n // --- live activity notification permission -------------------------------- - /// True once the user turned down the `POST_NOTIFICATIONS` prompt raised by the first - /// `LiveActivity.start()` on Android 13+. `CN1LiveActivityManager` consults this so a refusal - /// makes `isLiveActivitySupported()` report false (the honest answer) instead of re-prompting - /// on every start. Granting notifications later in the system settings makes - /// `areNotificationsEnabled()` true again, which is checked first, so the flag never traps an - /// app that the user changed their mind about. - public static boolean isNotificationPermissionRefused(Context ctx) { - return prefs(ctx).getBoolean(KEY_NOTIFICATIONS_REFUSED, false); + /// How many times `LiveActivity.start()` has raised the `POST_NOTIFICATIONS` prompt on + /// Android 13+ without ending up granted. `CN1LiveActivityManager` bounds the prompt at two + /// attempts, mirroring Android's own two-strike model, so an outcome it cannot tell apart -- + /// an explicit "Don't allow", a dialog the user dismissed without choosing, or a request the + /// system auto-denied without showing anything -- costs at most one more attempt instead of + /// being locked in as a permanent refusal on the first one. + public static int getNotificationPromptCount(Context ctx) { + return prefs(ctx).getInt(KEY_NOTIFICATION_PROMPTS, 0); } - /// Records the outcome of the `POST_NOTIFICATIONS` prompt; see - /// [#isNotificationPermissionRefused(Context)]. - public static void setNotificationPermissionRefused(Context ctx, boolean refused) { + /// Counts one raised prompt; see [#getNotificationPromptCount(Context)]. + public static void recordNotificationPrompt(Context ctx) { SharedPreferences prefs = prefs(ctx); - if (prefs.getBoolean(KEY_NOTIFICATIONS_REFUSED, false) != refused) { - prefs.edit().putBoolean(KEY_NOTIFICATIONS_REFUSED, refused).apply(); + prefs.edit().putInt(KEY_NOTIFICATION_PROMPTS, + prefs.getInt(KEY_NOTIFICATION_PROMPTS, 0) + 1).apply(); + } + + /// Forgets the prompt count once the permission is held, so a user who grants, later revokes + /// in the system settings and comes back gets the same two attempts a fresh install does. + public static void clearNotificationPrompts(Context ctx) { + SharedPreferences prefs = prefs(ctx); + if (prefs.getInt(KEY_NOTIFICATION_PROMPTS, 0) != 0) { + prefs.edit().remove(KEY_NOTIFICATION_PROMPTS).apply(); } } diff --git a/docs/developer-guide/External-Surfaces.asciidoc b/docs/developer-guide/External-Surfaces.asciidoc index cb88eb437cd..2024e8fb56d 100644 --- a/docs/developer-guide/External-Surfaces.asciidoc +++ b/docs/developer-guide/External-Surfaces.asciidoc @@ -123,7 +123,7 @@ The simulator preview renders the running activity as a mock Dynamic Island pill 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; on Android 13 and newer the first `start(...)` prompts for the notification permission, which the build declares for you, and `isSupported()` reports false from then on if the user declines. On desktop it appears in the simulator preview or as a floating window. +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; on Android 13 and newer the first `start(...)` prompts for the notification permission, which the build declares for you, and `isSupported()` reports false once the user has turned that prompt down twice. On desktop it appears in the simulator preview or as a floating window. === Actions and cold start @@ -177,7 +177,7 @@ Widget taps deep link back into the app through the `cn1surface://` URL scheme, ==== 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, which on Android 13 and newer require the `POST_NOTIFICATIONS` runtime permission: the build declares it for you when `surfaces.json` sets `"liveActivities": true`, and the first `LiveActivity.start(...)` raises the system prompt, blocking the calling thread until the user answers. Start the first activity while your app is in the foreground -- there is no UI to prompt from in a background service or push handler, so a start from one before the permission is granted is refused (`adb logcat -s CN1Surfaces` says which case you hit). 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. +Widgets are rendered through `RemoteViews` by generated per-kind providers; no Android-specific build hints are needed, and the per-kind sizing metadata comes from `surfaces.json`. Timeline entry flips are scheduled with inexact alarms (a 30-second window) to avoid the exact-alarm permission by default; apps that need to-the-second flips can opt in with the `android.surfaces.exactAlarms` build hint. Second-precision countdowns still tick natively through `Chronometer`. Live activities lower to ongoing notifications, which on Android 13 and newer require the `POST_NOTIFICATIONS` runtime permission: the build declares it for you when `surfaces.json` sets `"liveActivities": true`, and the first `LiveActivity.start(...)` raises the system prompt, blocking the calling thread until the user answers. Codename One raises it at most twice across an install -- Android auto-denies after two refusals anyway -- and only counts an attempt that actually reached the user, so a dismissed dialog costs one attempt rather than disabling live activities outright. Start the first activity while your app is in the foreground: there is no UI to prompt from in a background service or push handler, so a start from one before the permission is granted is refused without spending an attempt (`adb logcat -s CN1Surfaces` says which case you hit). A grant from anywhere counts -- these prompts, push registration, `Display.requestNotificationPermission(...)` or the system settings -- because the live permission state is always checked first. The approximations listed in the node catalog table apply: font weights collapse to regular/bold, circular progress falls back to linear, relative dates refresh only on entry flips, and vector nodes render as bitmaps. ==== Desktop, Windows, and Linux From 9597059ae32765fb81a90eae6bca63fd3b0e4ef6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:44:37 +0700 Subject: [PATCH 03/17] Address review round 2: retire the count on any observed grant, count only answers Codex P2: isSupported() returns early the moment areNotificationsEnabled() is true, so an app that observes a grant only through that call never cleared the stored prompt count. A later revoke then left one attempt instead of the two CN1SurfaceStore.clearNotificationPrompts documents. Clear it there too -- observing the grant is what retires earlier refusals, not the path it arrived by. Copilot: recordNotificationPrompt ran before the request, so a granted outcome transiently incremented the count, and an app killed while the dialog was up spent an attempt on a question the user never answered. Record only once the request comes back ungranted; the throw path does not record either, for the same reason. This also matches what the counter's own javadoc claims to count. Vale (the failing `build` job): Microsoft.Auto rejects the hyphen in "auto-denies". Reworded to "Android stops showing the dialog after two refusals anyway", which reads better than the unhyphenated spelling would. android module compiles with SpotBugs at zero findings; core-unittests Surface* 51/51; vale clean on the developer guide. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/android/surfaces/CN1LiveActivityManager.java | 11 ++++++++++- docs/developer-guide/External-Surfaces.asciidoc | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java index 9f463db7fde..f432414aaf1 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -83,6 +83,12 @@ public static boolean isSupported(Context ctx) { return false; } if (nm.areNotificationsEnabled()) { + // Observing the grant is what retires earlier refusals, not the path it arrived + // by -- these prompts, push registration, Display.requestNotificationPermission + // or the settings screen. Doing it here too means a grant the app only ever + // observes through isSupported still resets the budget, so a later revoke starts + // over with the full two attempts instead of a stale count. + CN1SurfaceStore.clearNotificationPrompts(ctx); return true; } if (Build.VERSION.SDK_INT < 33) { @@ -209,13 +215,13 @@ private static boolean ensureNotificationPermission(Context ctx) { + "\"liveActivities\": true -- add that and rebuild."); return false; } - CN1SurfaceStore.recordNotificationPrompt(ctx); boolean granted; try { granted = AndroidImplementation.checkForPermission( "android.permission.POST_NOTIFICATIONS", "This is required to show live activities", true); } catch (Throwable t) { + // the request never completed, so it is not an attempt the user spent Log.w(TAG, "Failed to request the POST_NOTIFICATIONS permission", t); return false; } @@ -223,6 +229,9 @@ private static boolean ensureNotificationPermission(Context ctx) { CN1SurfaceStore.clearNotificationPrompts(ctx); return true; } + // counted only now that the request came back ungranted: a prompt the app died during, + // or one that threw, is not an answer and must not spend part of the budget + CN1SurfaceStore.recordNotificationPrompt(ctx); Log.w(TAG, "Live activities are unavailable for now: POST_NOTIFICATIONS was not granted " + "(attempt " + CN1SurfaceStore.getNotificationPromptCount(ctx) + " of " + MAX_NOTIFICATION_PROMPTS + ")."); diff --git a/docs/developer-guide/External-Surfaces.asciidoc b/docs/developer-guide/External-Surfaces.asciidoc index 2024e8fb56d..498ada763f1 100644 --- a/docs/developer-guide/External-Surfaces.asciidoc +++ b/docs/developer-guide/External-Surfaces.asciidoc @@ -177,7 +177,7 @@ Widget taps deep link back into the app through the `cn1surface://` URL scheme, ==== 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, which on Android 13 and newer require the `POST_NOTIFICATIONS` runtime permission: the build declares it for you when `surfaces.json` sets `"liveActivities": true`, and the first `LiveActivity.start(...)` raises the system prompt, blocking the calling thread until the user answers. Codename One raises it at most twice across an install -- Android auto-denies after two refusals anyway -- and only counts an attempt that actually reached the user, so a dismissed dialog costs one attempt rather than disabling live activities outright. Start the first activity while your app is in the foreground: there is no UI to prompt from in a background service or push handler, so a start from one before the permission is granted is refused without spending an attempt (`adb logcat -s CN1Surfaces` says which case you hit). A grant from anywhere counts -- these prompts, push registration, `Display.requestNotificationPermission(...)` or the system settings -- because the live permission state is always checked first. The approximations listed in the node catalog table apply: font weights collapse to regular/bold, circular progress falls back to linear, relative dates refresh only on entry flips, and vector nodes render as bitmaps. +Widgets are rendered through `RemoteViews` by generated per-kind providers; no Android-specific build hints are needed, and the per-kind sizing metadata comes from `surfaces.json`. Timeline entry flips are scheduled with inexact alarms (a 30-second window) to avoid the exact-alarm permission by default; apps that need to-the-second flips can opt in with the `android.surfaces.exactAlarms` build hint. Second-precision countdowns still tick natively through `Chronometer`. Live activities lower to ongoing notifications, which on Android 13 and newer require the `POST_NOTIFICATIONS` runtime permission: the build declares it for you when `surfaces.json` sets `"liveActivities": true`, and the first `LiveActivity.start(...)` raises the system prompt, blocking the calling thread until the user answers. Codename One raises it at most twice across an install -- Android stops showing the dialog after two refusals anyway -- and only counts an attempt that reached the user, so a dismissed dialog costs one attempt rather than disabling live activities outright. Start the first activity while your app is in the foreground: there is no UI to prompt from in a background service or push handler, so a start from one before the permission is granted is refused without spending an attempt (`adb logcat -s CN1Surfaces` says which case you hit). A grant from anywhere counts -- these prompts, push registration, `Display.requestNotificationPermission(...)` or the system settings -- because the live permission state is always checked first. The approximations listed in the node catalog table apply: font weights collapse to regular/bold, circular progress falls back to linear, relative dates refresh only on entry flips, and vector nodes render as bitmaps. ==== Desktop, Windows, and Linux From 4d74fc51e166a66d8b0d3fc9ec14d8e1761f11e8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:51:52 +0700 Subject: [PATCH 04/17] Address review round 3: serialize the notification permission request Codex P2: LiveActivity.start is documented as callable from any thread, and checkForPermission drives the activity's one shared requestForPermission flag and request code. Two concurrent starts on a fresh install therefore both passed canPromptAgain, both entered the request, and a single dialog outcome released both callers -- which then each recorded an attempt, spending the whole two-prompt budget on one user answer. Guard the request with a static lock and re-read the permission under it, so a caller that waited sees whatever the winner produced: granted returns true without a second dialog, and an outcome is counted once. The re-check has to be inside the lock rather than only at the top, otherwise the waiter still acts on the state it sampled before blocking. Holding the lock across a user-visible dialog is intended -- start already documents that it blocks the calling thread until the user answers -- and it is the only lock on this path, so there is no ordering hazard. android module compiles with SpotBugs at zero findings; core-unittests Surface* 51/51. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/CN1LiveActivityManager.java | 97 +++++++++++-------- 1 file changed, 55 insertions(+), 42 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java index f432414aaf1..7752b5b87d6 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -62,6 +62,8 @@ public final class CN1LiveActivityManager { /// Prompt attempts before live activities report unsupported; Android's own model auto-denies /// after two refusals, so a third attempt would never reach the user anyway. private static final int MAX_NOTIFICATION_PROMPTS = 2; + /// Guards the permission request so concurrent starts raise one dialog and count one answer. + private static final Object PERMISSION_LOCK = new Object(); private CN1LiveActivityManager() { } @@ -192,50 +194,61 @@ private static boolean ensureNotificationPermission(Context ctx) { CN1SurfaceStore.clearNotificationPrompts(ctx); return true; } - if (!canPromptAgain(ctx)) { - Log.w(TAG, "Live activities are unavailable: POST_NOTIFICATIONS was refused twice. " - + "LiveActivity.isSupported() reports false until the user enables " - + "notifications for this app in the system settings."); - return false; - } - if (!hasForegroundActivity()) { - // nothing to prompt from -- a live activity started from a background service, a - // push, or with the app stopped. Not counted as an attempt, so the next start with - // the app in front still asks. - Log.w(TAG, "Cannot start a live activity: POST_NOTIFICATIONS has not been granted " - + "and the app is not in the foreground to request it. Start the first live " - + "activity while the app is visible."); - return false; - } - if (isPermissionMissing(ctx)) { - // requesting an undeclared permission is auto-denied without any UI; not counted as - // an attempt either, so fixing the manifest is all it takes - Log.e(TAG, "Cannot start a live activity: POST_NOTIFICATIONS is missing from the " - + "manifest. The build declares it for apps whose surfaces.json sets " - + "\"liveActivities\": true -- add that and rebuild."); - return false; - } - boolean granted; - try { - granted = AndroidImplementation.checkForPermission( - "android.permission.POST_NOTIFICATIONS", - "This is required to show live activities", true); - } catch (Throwable t) { - // the request never completed, so it is not an attempt the user spent - Log.w(TAG, "Failed to request the POST_NOTIFICATIONS permission", t); + // `start` is callable from any thread and `checkForPermission` drives the activity's one + // shared request flag and request code, so two concurrent starts would otherwise raise a + // single dialog whose single outcome released both callers -- and then be counted twice, + // spending the whole budget on one answer. One request at a time; whoever waited re-reads + // the state the winner produced. + synchronized (PERMISSION_LOCK) { + if (hasPostNotificationsPermission(ctx)) { + CN1SurfaceStore.clearNotificationPrompts(ctx); + return true; + } + if (!canPromptAgain(ctx)) { + Log.w(TAG, "Live activities are unavailable: POST_NOTIFICATIONS was refused " + + "twice. LiveActivity.isSupported() reports false until the user enables " + + "notifications for this app in the system settings."); + return false; + } + if (!hasForegroundActivity()) { + // nothing to prompt from -- a live activity started from a background service, a + // push, or with the app stopped. Not counted as an attempt, so the next start + // with the app in front still asks. + Log.w(TAG, "Cannot start a live activity: POST_NOTIFICATIONS has not been granted " + + "and the app is not in the foreground to request it. Start the first " + + "live activity while the app is visible."); + return false; + } + if (isPermissionMissing(ctx)) { + // requesting an undeclared permission is auto-denied without any UI; not counted + // as an attempt either, so fixing the manifest is all it takes + Log.e(TAG, "Cannot start a live activity: POST_NOTIFICATIONS is missing from the " + + "manifest. The build declares it for apps whose surfaces.json sets " + + "\"liveActivities\": true -- add that and rebuild."); + return false; + } + boolean granted; + try { + granted = AndroidImplementation.checkForPermission( + "android.permission.POST_NOTIFICATIONS", + "This is required to show live activities", true); + } catch (Throwable t) { + // the request never completed, so it is not an attempt the user spent + Log.w(TAG, "Failed to request the POST_NOTIFICATIONS permission", t); + return false; + } + if (granted) { + CN1SurfaceStore.clearNotificationPrompts(ctx); + return true; + } + // counted only now that the request came back ungranted: a prompt the app died + // during, or one that threw, is not an answer and must not spend part of the budget + CN1SurfaceStore.recordNotificationPrompt(ctx); + Log.w(TAG, "Live activities are unavailable for now: POST_NOTIFICATIONS was not " + + "granted (attempt " + CN1SurfaceStore.getNotificationPromptCount(ctx) + + " of " + MAX_NOTIFICATION_PROMPTS + ")."); return false; } - if (granted) { - CN1SurfaceStore.clearNotificationPrompts(ctx); - return true; - } - // counted only now that the request came back ungranted: a prompt the app died during, - // or one that threw, is not an answer and must not spend part of the budget - CN1SurfaceStore.recordNotificationPrompt(ctx); - Log.w(TAG, "Live activities are unavailable for now: POST_NOTIFICATIONS was not granted " - + "(attempt " + CN1SurfaceStore.getNotificationPromptCount(ctx) + " of " - + MAX_NOTIFICATION_PROMPTS + ")."); - return false; } /// True while a prompt attempt remains; see From 4c10300f3815ee35951fd19a637eb6355c6c3d8b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:01:15 +0700 Subject: [PATCH 05/17] Address review round 4: log the exhausted-budget refusal, correct two claims Copilot: start() tested isSupported() first, which reports false exactly when the prompt budget is spent, so it short-circuited past the one branch that logs why the start was refused -- the case a developer most needs to see, in a PR whose whole point is that this failure used to be silent. Ask for the permission first. Nothing gets prompted that isSupported() would have rejected on capability grounds, because POST_NOTIFICATIONS only exists from API 33 and live activities need API 24. Copilot: the guide claimed attempts are only counted when the prompt "reached the user", which the implementation cannot tell -- an OS auto-deny is reported exactly like a refusal. What it actually skips counting is a request it never issued (no foreground activity, permission missing from the manifest) or one that threw. Reworded to say that, and to admit a dismissed dialog does cost an attempt. Codex: PERMISSION_LOCK serializes surfaces against itself, not against a camera or location request in flight elsewhere -- those share the same activity-wide requestForPermission flag and request code 1, and their callback can release this caller early. That is a pre-existing defect in the shared permission machinery, and fixing it properly means per-request completion state in AndroidImplementation, which touches every permission in the port and wants its own change. Documented at the lock instead, with why the damage is bounded: a spuriously counted attempt costs one of two prompts, and any later grant from any source clears the count. android module compiles with SpotBugs at zero findings; core-unittests Surface* 51/51; vale clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1LiveActivityManager.java | 15 ++++++++++++++- docs/developer-guide/External-Surfaces.asciidoc | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java index 7752b5b87d6..74dba7100b5 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -110,7 +110,12 @@ public static boolean isSupported(Context ctx) { /// Raises the `POST_NOTIFICATIONS` prompt first when Android 13+ needs it, blocking the /// calling thread until the user answers. public static String start(Context ctx, String descriptorJson, Map images) { - if (!isSupported(ctx) || !ensureNotificationPermission(ctx)) { + // permission first: `isSupported` reports false once the prompt budget is spent, so + // testing it first would short-circuit past the one place that logs *why* a start was + // refused -- precisely the case a developer needs to see. Nothing is prompted that + // `isSupported` would have rejected on capability grounds either, since the permission + // only exists from API 33 and live activities need API 24. + if (!ensureNotificationPermission(ctx) || !isSupported(ctx)) { return null; } try { @@ -199,6 +204,14 @@ private static boolean ensureNotificationPermission(Context ctx) { // single dialog whose single outcome released both callers -- and then be counted twice, // spending the whole budget on one answer. One request at a time; whoever waited re-reads // the state the winner produced. + // + // This serializes surfaces against itself only. A camera or location request in flight + // elsewhere still shares that same activity-wide flag and request code, and its callback + // can release this one early -- an existing limitation of the shared permission machinery + // rather than of this path, and one that needs per-request completion state in + // `AndroidImplementation` to fix properly. The damage here is bounded: a spuriously + // counted attempt costs the user one of two prompts, and any later grant, from any + // source, clears the count. synchronized (PERMISSION_LOCK) { if (hasPostNotificationsPermission(ctx)) { CN1SurfaceStore.clearNotificationPrompts(ctx); diff --git a/docs/developer-guide/External-Surfaces.asciidoc b/docs/developer-guide/External-Surfaces.asciidoc index 498ada763f1..8537d826699 100644 --- a/docs/developer-guide/External-Surfaces.asciidoc +++ b/docs/developer-guide/External-Surfaces.asciidoc @@ -177,7 +177,7 @@ Widget taps deep link back into the app through the `cn1surface://` URL scheme, ==== 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, which on Android 13 and newer require the `POST_NOTIFICATIONS` runtime permission: the build declares it for you when `surfaces.json` sets `"liveActivities": true`, and the first `LiveActivity.start(...)` raises the system prompt, blocking the calling thread until the user answers. Codename One raises it at most twice across an install -- Android stops showing the dialog after two refusals anyway -- and only counts an attempt that reached the user, so a dismissed dialog costs one attempt rather than disabling live activities outright. Start the first activity while your app is in the foreground: there is no UI to prompt from in a background service or push handler, so a start from one before the permission is granted is refused without spending an attempt (`adb logcat -s CN1Surfaces` says which case you hit). A grant from anywhere counts -- these prompts, push registration, `Display.requestNotificationPermission(...)` or the system settings -- because the live permission state is always checked first. The approximations listed in the node catalog table apply: font weights collapse to regular/bold, circular progress falls back to linear, relative dates refresh only on entry flips, and vector nodes render as bitmaps. +Widgets are rendered through `RemoteViews` by generated per-kind providers; no Android-specific build hints are needed, and the per-kind sizing metadata comes from `surfaces.json`. Timeline entry flips are scheduled with inexact alarms (a 30-second window) to avoid the exact-alarm permission by default; apps that need to-the-second flips can opt in with the `android.surfaces.exactAlarms` build hint. Second-precision countdowns still tick natively through `Chronometer`. Live activities lower to ongoing notifications, which on Android 13 and newer require the `POST_NOTIFICATIONS` runtime permission: the build declares it for you when `surfaces.json` sets `"liveActivities": true`, and the first `LiveActivity.start(...)` raises the system prompt, blocking the calling thread until the user answers. Codename One raises it at most twice across an install -- Android stops showing the dialog after two refusals anyway -- and spends an attempt only on a request it managed to issue. Android reports a dismissed dialog exactly as it reports a refusal, so dismissing one does cost an attempt, but one rather than the whole budget. Start the first activity while your app is in the foreground: there is no UI to prompt from in a background service or push handler, so a start from one before the permission is granted is refused without spending an attempt (`adb logcat -s CN1Surfaces` says which case you hit, including a start refused because the budget is gone). A grant from anywhere counts -- these prompts, push registration, `Display.requestNotificationPermission(...)` or the system settings -- because the live permission state is always checked first. The approximations listed in the node catalog table apply: font weights collapse to regular/bold, circular progress falls back to linear, relative dates refresh only on entry flips, and vector nodes render as bitmaps. ==== Desktop, Windows, and Linux From 2474926bb1c55b035f7b8318d9a3e447fee1a16f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:12:53 +0700 Subject: [PATCH 06/17] Address review round 5: settle isSupported without a declaration, scope the budget to the install Codex: an app that publishes widgets but never sets "liveActivities": true gets no POST_NOTIFICATIONS declaration from the build, and isSupported() then returned true forever on API 33+ -- ungranted, count zero -- while every start() stopped at the manifest check without spending an attempt, so the count could never settle it. Deliberately not counting those attempts is what made the lie permanent. isSupported() now consults the manifest too. The diagnostic still reaches the log because round 4 moved ensureNotificationPermission ahead of isSupported in start(). The manifest lookup is now cached: isSupported is called per screen or per frame by real apps, and a binder round trip per call is not free. Only a definite verdict is cached -- a failed lookup is retried rather than frozen -- and a manifest cannot change under a live process, since an app update kills it. Codex: Codename One builds allow backup by default, so the cn1surfaces preferences ride along to a reinstall or a new device. A restored count of 2 would suppress the dialog forever on what the API documents as a fresh install: the same permanent silent failure this whole path exists to remove. The count is now stamped with the installation that earned it, using firstInstallTime -- preserved across app updates, which must not hand back a spent budget, and changed by a genuine reinstall or a restore onto another device. A count whose stamp does not match reads as zero. That keeps it self-contained; excluding the file from backup would have meant build-side backup rules for one integer. android module compiles with SpotBugs at zero findings; core-unittests Surface* 51/51. Unrelated CI flake on the previous head: HealthEdtDeliveryTest .aFacadeActionDeliversOnTheEdt failed once in build-test (8). It is an EDT timing test in com.codename1.health, nothing this branch touches, it passes locally 7/7, and master is green. This push re-runs it. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/CN1LiveActivityManager.java | 38 +++++++++++---- .../android/surfaces/CN1SurfaceStore.java | 47 ++++++++++++++++--- 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java index 74dba7100b5..d17eeaa32f7 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -64,6 +64,10 @@ public final class CN1LiveActivityManager { private static final int MAX_NOTIFICATION_PROMPTS = 2; /// Guards the permission request so concurrent starts raise one dialog and count one answer. private static final Object PERMISSION_LOCK = new Object(); + private static final int DECLARED_PRESENT = 1; + private static final int DECLARED_MISSING = 2; + /// Cached manifest verdict: 0 not looked up yet, otherwise one of the DECLARED_ constants. + private static volatile int permissionDeclaredState; private CN1LiveActivityManager() { } @@ -99,8 +103,14 @@ public static boolean isSupported(Context ctx) { } // From API 33 notifications also read as disabled while POST_NOTIFICATIONS is merely // ungranted, which is the state of every fresh install. Granted-but-disabled is the - // settled choice again; ungranted is supported while a prompt attempt remains. - return !hasPostNotificationsPermission(ctx) && canPromptAgain(ctx); + // settled choice again; ungranted is supported while the permission is one the app + // can actually ask for and a prompt attempt remains. The manifest test is what keeps + // an app that publishes widgets but never set "liveActivities": true from reporting + // supported forever: no declaration means no prompt, so no attempt is ever spent and + // the count alone would never settle. + return !hasPostNotificationsPermission(ctx) + && !isPermissionMissing(ctx) + && canPromptAgain(ctx); } catch (Throwable t) { return false; } @@ -286,6 +296,14 @@ private static boolean hasForegroundActivity() { /// `AndroidImplementation#getRequestedPermissions()` is what keeps those apart -- that helper /// flattens both a missing package and an unreadable one into the same empty list. private static boolean isPermissionMissing(Context ctx) { + // `isSupported` consults this, and apps do call it per screen or per frame, so the binder + // round trip is cached. A manifest cannot change under a live process -- an app update + // kills it first -- and only a definite answer is cached, so a lookup that failed is + // retried rather than frozen. + int cached = permissionDeclaredState; + if (cached != 0) { + return cached == DECLARED_MISSING; + } try { android.content.pm.PackageInfo info = ctx.getPackageManager().getPackageInfo( ctx.getPackageName(), PackageManager.GET_PERMISSIONS); @@ -293,15 +311,17 @@ private static boolean isPermissionMissing(Context ctx) { return false; } String[] declared = info.requestedPermissions; - if (declared == null) { - return true; - } - for (String p : declared) { - if ("android.permission.POST_NOTIFICATIONS".equals(p)) { - return false; + boolean missing = true; + if (declared != null) { + for (String p : declared) { + if ("android.permission.POST_NOTIFICATIONS".equals(p)) { + missing = false; + break; + } } } - return true; + permissionDeclaredState = missing ? DECLARED_MISSING : DECLARED_PRESENT; + return missing; } catch (Throwable t) { return false; } diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java index 2e232a0bd24..1c11e52ec10 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java @@ -57,6 +57,9 @@ public final class CN1SurfaceStore { private static final String KEY_FETCH_CLASS = "bgFetchClass"; private static final String KEY_FETCH_AT_PREFIX = "bgFetchAt_"; private static final String KEY_NOTIFICATION_PROMPTS = "notificationPrompts"; + private static final String KEY_PROMPTS_INSTALL = "notificationPromptsInstall"; + /// Cached `firstInstallTime`; constant for the life of the process. + private static volatile long installStamp; private CN1SurfaceStore() { } @@ -232,24 +235,56 @@ public static boolean tryClaimBackgroundFetch(Context ctx, String kindId, long n /// an explicit "Don't allow", a dialog the user dismissed without choosing, or a request the /// system auto-denied without showing anything -- costs at most one more attempt instead of /// being locked in as a permanent refusal on the first one. + /// + /// The count is scoped to one installation. Codename One builds allow backup by default, so + /// these preferences ride along to a reinstall or a new device, where a restored "2" would + /// silently suppress the dialog forever on what the API documents as a fresh install -- + /// exactly the permanent silent failure this whole path exists to remove. Stamping the count + /// with the install it was earned against costs one cached lookup and needs no build-side + /// backup rules. public static int getNotificationPromptCount(Context ctx) { - return prefs(ctx).getInt(KEY_NOTIFICATION_PROMPTS, 0); + SharedPreferences prefs = prefs(ctx); + if (prefs.getLong(KEY_PROMPTS_INSTALL, 0) != installStamp(ctx)) { + return 0; + } + return prefs.getInt(KEY_NOTIFICATION_PROMPTS, 0); } /// Counts one raised prompt; see [#getNotificationPromptCount(Context)]. public static void recordNotificationPrompt(Context ctx) { - SharedPreferences prefs = prefs(ctx); - prefs.edit().putInt(KEY_NOTIFICATION_PROMPTS, - prefs.getInt(KEY_NOTIFICATION_PROMPTS, 0) + 1).apply(); + prefs(ctx).edit() + .putInt(KEY_NOTIFICATION_PROMPTS, getNotificationPromptCount(ctx) + 1) + .putLong(KEY_PROMPTS_INSTALL, installStamp(ctx)) + .apply(); } /// Forgets the prompt count once the permission is held, so a user who grants, later revokes /// in the system settings and comes back gets the same two attempts a fresh install does. public static void clearNotificationPrompts(Context ctx) { SharedPreferences prefs = prefs(ctx); - if (prefs.getInt(KEY_NOTIFICATION_PROMPTS, 0) != 0) { - prefs.edit().remove(KEY_NOTIFICATION_PROMPTS).apply(); + if (prefs.contains(KEY_NOTIFICATION_PROMPTS) || prefs.contains(KEY_PROMPTS_INSTALL)) { + prefs.edit().remove(KEY_NOTIFICATION_PROMPTS).remove(KEY_PROMPTS_INSTALL).apply(); + } + } + + /// Identifies the current installation. `firstInstallTime` survives app *updates* -- which + /// must not hand back a spent budget -- but changes on a genuine reinstall and on a restore + /// to another device, which is precisely the line this needs to draw. + private static long installStamp(Context ctx) { + long stamp = installStamp; + if (stamp != 0) { + return stamp; + } + try { + stamp = ctx.getPackageManager() + .getPackageInfo(ctx.getPackageName(), 0).firstInstallTime; + } catch (Throwable t) { + // unknown: 0 matches the default of an unstamped preference, so a count written + // under a known stamp is discarded rather than trusted + stamp = 0; } + installStamp = stamp; + return stamp; } // --- internals ------------------------------------------------------------ From f13af5338c4d6af102ff5153e66925f234039bd0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:22:42 +0700 Subject: [PATCH 07/17] Address review round 6: put the settled diagnostics where the decision is made Both bots caught the same thing, and it invalidates a claim I made in the round 5 replies. LiveActivity.start preflights on isLiveActivitySupported and returns an inert handle when it is false, so the manager's start -- and every log in ensureNotificationPermission -- is unreachable for exactly the states that are already settled. Round 4 reordered those two calls *inside* the manager, which does nothing for the public API path above it, and round 5 then made it worse by adding the missing-manifest case to isSupported's false conditions, silencing a diagnostic that used to fire. Move both settled diagnostics into isSupported, where the decision is actually made and the last place a developer's logcat can see it. Once per process per reason, because apps poll isSupported per screen or per frame and an unguarded log would bury the rest of logcat; ensureNotificationPermission routes its own copies through the same guards, so a direct call into the manager still explains itself without double-reporting. The comment in start() that claimed it was "the one place that logs why a start was refused" was wrong for the same reason and now says what actually happens. Copilot: installStamp cached a 0 from a failed getPackageInfo, which pinned the stamp for the rest of the process and quietly handed back every prompt count as zero. Return the unknown without caching it so a later lookup can still succeed. Copilot: the guide promised logcat would report a start refused for an exhausted budget, which stopped being true once isSupported settled that case earlier. Reworded to name isSupported() as the programmatic signal and logcat as the explanation, which is what the code now does. android module compiles with SpotBugs at zero findings; core-unittests Surface* 51/51; vale clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/CN1LiveActivityManager.java | 76 ++++++++++++++----- .../android/surfaces/CN1SurfaceStore.java | 6 +- .../External-Surfaces.asciidoc | 2 +- 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java index d17eeaa32f7..bb63f0dd97b 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -68,6 +68,8 @@ public final class CN1LiveActivityManager { private static final int DECLARED_MISSING = 2; /// Cached manifest verdict: 0 not looked up yet, otherwise one of the DECLARED_ constants. private static volatile int permissionDeclaredState; + private static volatile boolean loggedMissingManifest; + private static volatile boolean loggedBudgetExhausted; private CN1LiveActivityManager() { } @@ -102,15 +104,28 @@ public static boolean isSupported(Context ctx) { return false; } // From API 33 notifications also read as disabled while POST_NOTIFICATIONS is merely - // ungranted, which is the state of every fresh install. Granted-but-disabled is the - // settled choice again; ungranted is supported while the permission is one the app - // can actually ask for and a prompt attempt remains. The manifest test is what keeps - // an app that publishes widgets but never set "liveActivities": true from reporting - // supported forever: no declaration means no prompt, so no attempt is ever spent and - // the count alone would never settle. - return !hasPostNotificationsPermission(ctx) - && !isPermissionMissing(ctx) - && canPromptAgain(ctx); + // ungranted, which is the state of every fresh install. + if (hasPostNotificationsPermission(ctx)) { + // held, and notifications are still off: a deliberate choice, not a pending + // prompt, so there is nothing to ask for + return false; + } + // Every settled "no" below has to explain itself right here. `LiveActivity.start` + // preflights on this method and returns an inert handle when it is false, so the + // manager's own start -- and the diagnostics in `ensureNotificationPermission` -- are + // never reached for these states. This is the last point a developer's logcat sees. + // Once per process per reason, since apps call this per screen or per frame. + if (isPermissionMissing(ctx)) { + // no declaration means no prompt, so no attempt is ever spent and the count + // alone would never settle this into false + logMissingManifestOnce(); + return false; + } + if (!canPromptAgain(ctx)) { + logBudgetExhaustedOnce(); + return false; + } + return true; } catch (Throwable t) { return false; } @@ -120,11 +135,13 @@ public static boolean isSupported(Context ctx) { /// Raises the `POST_NOTIFICATIONS` prompt first when Android 13+ needs it, blocking the /// calling thread until the user answers. public static String start(Context ctx, String descriptorJson, Map images) { - // permission first: `isSupported` reports false once the prompt budget is spent, so - // testing it first would short-circuit past the one place that logs *why* a start was - // refused -- precisely the case a developer needs to see. Nothing is prompted that - // `isSupported` would have rejected on capability grounds either, since the permission - // only exists from API 33 and live activities need API 24. + // Permission first so that a direct call here still raises the prompt before the + // capability check refuses on a budget this very call might replenish. Note the public + // API does not arrive this way when support is already settled: `LiveActivity.start` + // preflights on `isLiveActivitySupported`, which is why the settled-state diagnostics + // live in `isSupported` rather than below. Nothing is prompted that `isSupported` would + // have rejected on capability grounds, since POST_NOTIFICATIONS only exists from API 33 + // and live activities need API 24. if (!ensureNotificationPermission(ctx) || !isSupported(ctx)) { return null; } @@ -228,9 +245,7 @@ private static boolean ensureNotificationPermission(Context ctx) { return true; } if (!canPromptAgain(ctx)) { - Log.w(TAG, "Live activities are unavailable: POST_NOTIFICATIONS was refused " - + "twice. LiveActivity.isSupported() reports false until the user enables " - + "notifications for this app in the system settings."); + logBudgetExhaustedOnce(); return false; } if (!hasForegroundActivity()) { @@ -245,9 +260,7 @@ private static boolean ensureNotificationPermission(Context ctx) { if (isPermissionMissing(ctx)) { // requesting an undeclared permission is auto-denied without any UI; not counted // as an attempt either, so fixing the manifest is all it takes - Log.e(TAG, "Cannot start a live activity: POST_NOTIFICATIONS is missing from the " - + "manifest. The build declares it for apps whose surfaces.json sets " - + "\"liveActivities\": true -- add that and rebuild."); + logMissingManifestOnce(); return false; } boolean granted; @@ -274,6 +287,29 @@ private static boolean ensureNotificationPermission(Context ctx) { } } + /// The two settled reasons live activities can be unavailable, each reported once per + /// process. `isSupported` is polled -- per screen, sometimes per frame -- so an unguarded log + /// would bury the rest of logcat. A duplicated line under a race is harmless. + private static void logMissingManifestOnce() { + if (loggedMissingManifest) { + return; + } + loggedMissingManifest = true; + Log.e(TAG, "Live activities are unavailable: POST_NOTIFICATIONS is missing from the " + + "manifest. The build declares it for apps whose surfaces.json sets " + + "\"liveActivities\": true -- add that and rebuild."); + } + + private static void logBudgetExhaustedOnce() { + if (loggedBudgetExhausted) { + return; + } + loggedBudgetExhausted = true; + Log.w(TAG, "Live activities are unavailable: POST_NOTIFICATIONS was refused twice. " + + "LiveActivity.isSupported() reports false until the user enables notifications " + + "for this app in the system settings."); + } + /// True while a prompt attempt remains; see /// `CN1SurfaceStore#getNotificationPromptCount(Context)`. private static boolean canPromptAgain(Context ctx) { diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java index 1c11e52ec10..eec30e55319 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java @@ -280,8 +280,10 @@ private static long installStamp(Context ctx) { .getPackageInfo(ctx.getPackageName(), 0).firstInstallTime; } catch (Throwable t) { // unknown: 0 matches the default of an unstamped preference, so a count written - // under a known stamp is discarded rather than trusted - stamp = 0; + // under a known stamp is discarded rather than trusted. Deliberately not cached -- + // pinning a transient lookup failure would disable the per-install scoping for the + // rest of the process and quietly hand every count back as zero. + return 0; } installStamp = stamp; return stamp; diff --git a/docs/developer-guide/External-Surfaces.asciidoc b/docs/developer-guide/External-Surfaces.asciidoc index 8537d826699..6f516ccdf46 100644 --- a/docs/developer-guide/External-Surfaces.asciidoc +++ b/docs/developer-guide/External-Surfaces.asciidoc @@ -177,7 +177,7 @@ Widget taps deep link back into the app through the `cn1surface://` URL scheme, ==== 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, which on Android 13 and newer require the `POST_NOTIFICATIONS` runtime permission: the build declares it for you when `surfaces.json` sets `"liveActivities": true`, and the first `LiveActivity.start(...)` raises the system prompt, blocking the calling thread until the user answers. Codename One raises it at most twice across an install -- Android stops showing the dialog after two refusals anyway -- and spends an attempt only on a request it managed to issue. Android reports a dismissed dialog exactly as it reports a refusal, so dismissing one does cost an attempt, but one rather than the whole budget. Start the first activity while your app is in the foreground: there is no UI to prompt from in a background service or push handler, so a start from one before the permission is granted is refused without spending an attempt (`adb logcat -s CN1Surfaces` says which case you hit, including a start refused because the budget is gone). A grant from anywhere counts -- these prompts, push registration, `Display.requestNotificationPermission(...)` or the system settings -- because the live permission state is always checked first. The approximations listed in the node catalog table apply: font weights collapse to regular/bold, circular progress falls back to linear, relative dates refresh only on entry flips, and vector nodes render as bitmaps. +Widgets are rendered through `RemoteViews` by generated per-kind providers; no Android-specific build hints are needed, and the per-kind sizing metadata comes from `surfaces.json`. Timeline entry flips are scheduled with inexact alarms (a 30-second window) to avoid the exact-alarm permission by default; apps that need to-the-second flips can opt in with the `android.surfaces.exactAlarms` build hint. Second-precision countdowns still tick natively through `Chronometer`. Live activities lower to ongoing notifications, which on Android 13 and newer require the `POST_NOTIFICATIONS` runtime permission: the build declares it for you when `surfaces.json` sets `"liveActivities": true`, and the first `LiveActivity.start(...)` raises the system prompt, blocking the calling thread until the user answers. Codename One raises it at most twice across an install -- Android stops showing the dialog after two refusals anyway -- and spends an attempt only on a request it managed to issue. Android reports a dismissed dialog exactly as it reports a refusal, so dismissing one does cost an attempt, but one rather than the whole budget. Start the first activity while your app is in the foreground: there is no UI to prompt from in a background service or push handler, so a start from one before the permission is granted is refused without spending an attempt. `LiveActivity.isSupported()` is the programmatic signal once the answer has settled -- a spent budget or, for an app that never declared the permission, a missing manifest entry -- and `adb logcat -s CN1Surfaces` explains every refusal, reporting each settled reason once. A grant from anywhere counts -- these prompts, push registration, `Display.requestNotificationPermission(...)` or the system settings -- because the live permission state is always checked first. The approximations listed in the node catalog table apply: font weights collapse to regular/bold, circular progress falls back to linear, relative dates refresh only on entry flips, and vector nodes render as bitmaps. ==== Desktop, Windows, and Linux From ad361de7a8aea9745e946c6ea88cbcb088f6a545 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:31:41 +0700 Subject: [PATCH 08/17] Address review round 7: guard the prompt against reentrancy, scope the stamp strictly Codex: PERMISSION_LOCK is a Java monitor, and Java monitors are reentrant. checkForPermission blocks through Display.invokeAndBlock, which by design keeps dispatching EDT work, so a timer or callSerially that starts another live activity re-enters ensureNotificationPermission on the very thread already holding the lock and walks straight through to a second requestPermissions -- both of which can then record an ungranted result and spend the whole budget on one dialog. The round 3 lock only ever closed the cross-thread case. Track the in-flight request explicitly and refuse the nested start. It cannot wait for the outer one without deadlocking itself, and an inert handle is what a refused start already returns. Copilot: an unknown install stamp was still being trusted. getNotificationPrompt Count compared it against the stored value, and a stored 0 matched a failed lookup, so a transient PackageManager failure could read a persisted count as current and suppress the dialog; recordNotificationPrompt likewise persisted attempts under a zero stamp, letting a repeated failure accumulate a budget no install owns. Unknown now means unscopeable in both directions: read nothing, write nothing. Erring toward prompting is the safe side, since a lookup failure of ours must never be what silently suppresses the dialog. Copilot: the guide said isSupported() reports false once the prompt is refused twice, omitting that it is also false when notifications are switched off for the app even with the permission held. The API javadoc already said both; the guide now agrees. android module compiles with SpotBugs at zero findings; core-unittests Surface* 51/51; vale clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/CN1LiveActivityManager.java | 20 +++++++++++++++++++ .../android/surfaces/CN1SurfaceStore.java | 18 +++++++++++++++-- .../External-Surfaces.asciidoc | 2 +- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java index bb63f0dd97b..3717edc6051 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -68,6 +68,10 @@ public final class CN1LiveActivityManager { private static final int DECLARED_MISSING = 2; /// Cached manifest verdict: 0 not looked up yet, otherwise one of the DECLARED_ constants. private static volatile int permissionDeclaredState; + /// True while `checkForPermission` is blocked on the system dialog. Read and written only + /// under `PERMISSION_LOCK`, which is what publishes it; it exists because that monitor is + /// reentrant and `invokeAndBlock` pumps the EDT underneath it. + private static boolean permissionRequestInFlight; private static volatile boolean loggedMissingManifest; private static volatile boolean loggedBudgetExhausted; @@ -244,6 +248,19 @@ private static boolean ensureNotificationPermission(Context ctx) { CN1SurfaceStore.clearNotificationPrompts(ctx); return true; } + // The monitor alone is not enough. `checkForPermission` blocks through + // `Display.invokeAndBlock`, which keeps dispatching EDT work, so a timer or + // `callSerially` that starts another live activity re-enters this method on the very + // thread already holding the lock -- and a Java monitor is reentrant, so it would + // walk straight through and raise a second dialog. Refuse instead: the nested start + // cannot wait for the outer one without deadlocking itself, and an inert handle is + // exactly what a refused start is documented to return. + if (permissionRequestInFlight) { + Log.w(TAG, "Ignoring a live activity start raised while the POST_NOTIFICATIONS " + + "prompt is still open; wait for the first start to return before " + + "starting another."); + return false; + } if (!canPromptAgain(ctx)) { logBudgetExhaustedOnce(); return false; @@ -264,6 +281,7 @@ private static boolean ensureNotificationPermission(Context ctx) { return false; } boolean granted; + permissionRequestInFlight = true; try { granted = AndroidImplementation.checkForPermission( "android.permission.POST_NOTIFICATIONS", @@ -272,6 +290,8 @@ private static boolean ensureNotificationPermission(Context ctx) { // the request never completed, so it is not an attempt the user spent Log.w(TAG, "Failed to request the POST_NOTIFICATIONS permission", t); return false; + } finally { + permissionRequestInFlight = false; } if (granted) { CN1SurfaceStore.clearNotificationPrompts(ctx); diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java index eec30e55319..d68f0e72453 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java @@ -243,8 +243,15 @@ public static boolean tryClaimBackgroundFetch(Context ctx, String kindId, long n /// with the install it was earned against costs one cached lookup and needs no build-side /// backup rules. public static int getNotificationPromptCount(Context ctx) { + long stamp = installStamp(ctx); + if (stamp == 0) { + // the install could not be identified, so no stored count can be attributed to it. + // Reading zero errs toward prompting, which is the safe direction: a lookup failure + // of ours must never be what silently suppresses the dialog. + return 0; + } SharedPreferences prefs = prefs(ctx); - if (prefs.getLong(KEY_PROMPTS_INSTALL, 0) != installStamp(ctx)) { + if (prefs.getLong(KEY_PROMPTS_INSTALL, 0) != stamp) { return 0; } return prefs.getInt(KEY_NOTIFICATION_PROMPTS, 0); @@ -252,9 +259,16 @@ public static int getNotificationPromptCount(Context ctx) { /// Counts one raised prompt; see [#getNotificationPromptCount(Context)]. public static void recordNotificationPrompt(Context ctx) { + long stamp = installStamp(ctx); + if (stamp == 0) { + // nothing to attribute the attempt to. Persisting it under a zero stamp would let a + // repeated lookup failure accumulate a budget that no install owns, and a later + // successful lookup could not tell that count apart from a legitimately unstamped one + return; + } prefs(ctx).edit() .putInt(KEY_NOTIFICATION_PROMPTS, getNotificationPromptCount(ctx) + 1) - .putLong(KEY_PROMPTS_INSTALL, installStamp(ctx)) + .putLong(KEY_PROMPTS_INSTALL, stamp) .apply(); } diff --git a/docs/developer-guide/External-Surfaces.asciidoc b/docs/developer-guide/External-Surfaces.asciidoc index 6f516ccdf46..a302964ab6f 100644 --- a/docs/developer-guide/External-Surfaces.asciidoc +++ b/docs/developer-guide/External-Surfaces.asciidoc @@ -123,7 +123,7 @@ The simulator preview renders the running activity as a mock Dynamic Island pill 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; on Android 13 and newer the first `start(...)` prompts for the notification permission, which the build declares for you, and `isSupported()` reports false once the user has turned that prompt down twice. On desktop it appears in the simulator preview or as a floating window. +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; on Android 13 and newer the first `start(...)` prompts for the notification permission, which the build declares for you, and `isSupported()` reports false once the user has turned that prompt down twice or has switched notifications off for the app. On desktop it appears in the simulator preview or as a floating window. === Actions and cold start From 76ded1ec1d70315c20ce040b9bc5dab6c34cf63b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:40:22 +0700 Subject: [PATCH 09/17] Address review round 8: let a waiting start adopt the answer, put the manifest first Codex: serializing was not the same as coalescing. Two background threads calling start concurrently would queue on the monitor, and once the first request came back denied and released it, the waiter found the budget merely decremented and opened a second dialog back to back -- one burst of starts spending the whole installation budget. The round 3 comment claimed the waiter "re-reads the state the winner produced", which was true and beside the point: the state it read still permitted another prompt. Callers now sample a generation counter before contending for the lock and, on finding it moved, adopt the answer of the request that ran while they waited instead of raising their own. The counter is bumped only once an answer actually came back, so a request that threw leaves waiters free to ask rather than adopting an outcome that never happened. A start issued later, with no request in between, still gets its turn. Copilot: ensureNotificationPermission tested the budget and the foreground before the manifest, so an app missing the declaration whose prompt count was already spent -- a restored preference file presents exactly that on a first run -- would be told the user had refused twice, which is both wrong and the one diagnosis it cannot act on. Manifest first now, matching the precedence isSupported already used. Copilot: the installStamp field and the installStamp(Context) helper that fills it shared a name. Field renamed cachedInstallStamp. android module compiles with SpotBugs at zero findings; core-unittests Surface* 51/51. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/CN1LiveActivityManager.java | 34 +++++++++++++++---- .../android/surfaces/CN1SurfaceStore.java | 9 ++--- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java index 3717edc6051..4d8d2720caa 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -72,6 +72,10 @@ public final class CN1LiveActivityManager { /// under `PERMISSION_LOCK`, which is what publishes it; it exists because that monitor is /// reentrant and `invokeAndBlock` pumps the EDT underneath it. private static boolean permissionRequestInFlight; + /// Incremented under `PERMISSION_LOCK` each time a request returns an answer. Volatile + /// because callers sample it before contending for the lock, which is how one that waited + /// recognises that the burst it belongs to has already been answered. + private static volatile long permissionRequestGeneration; private static volatile boolean loggedMissingManifest; private static volatile boolean loggedBudgetExhausted; @@ -236,6 +240,9 @@ private static boolean ensureNotificationPermission(Context ctx) { // spending the whole budget on one answer. One request at a time; whoever waited re-reads // the state the winner produced. // + // Sampled before the lock so that a caller which then waits can tell a request completed + // underneath it; see the generation check below. + long seenGeneration = permissionRequestGeneration; // This serializes surfaces against itself only. A camera or location request in flight // elsewhere still shares that same activity-wide flag and request code, and its callback // can release this one early -- an existing limitation of the shared permission machinery @@ -261,6 +268,24 @@ private static boolean ensureNotificationPermission(Context ctx) { + "starting another."); return false; } + if (permissionRequestGeneration != seenGeneration) { + // A request finished while this caller was blocked on the lock, so it belongs to + // that same burst and adopts the answer rather than asking again. Serializing + // alone was not enough: the waiter would find the budget merely decremented and + // open a second dialog back to back, spending the whole budget on one burst of + // starts. A start issued later, with no request in between, still gets its turn. + return hasPostNotificationsPermission(ctx); + } + // Manifest first, matching the precedence in `isSupported`. A build that never + // declared the permission is the most actionable diagnosis and the only one the + // developer can act on directly, so it must not be masked by a spent budget -- which + // a restored preference file can present on a first run -- or by a transient + // background start. Requesting an undeclared permission is auto-denied without any + // UI, and is not counted as an attempt, so fixing the manifest is all it takes. + if (isPermissionMissing(ctx)) { + logMissingManifestOnce(); + return false; + } if (!canPromptAgain(ctx)) { logBudgetExhaustedOnce(); return false; @@ -274,18 +299,15 @@ private static boolean ensureNotificationPermission(Context ctx) { + "live activity while the app is visible."); return false; } - if (isPermissionMissing(ctx)) { - // requesting an undeclared permission is auto-denied without any UI; not counted - // as an attempt either, so fixing the manifest is all it takes - logMissingManifestOnce(); - return false; - } boolean granted; permissionRequestInFlight = true; try { granted = AndroidImplementation.checkForPermission( "android.permission.POST_NOTIFICATIONS", "This is required to show live activities", true); + // bumped only once an answer actually came back, so a throw leaves waiters free + // to ask rather than adopting an outcome that never happened + permissionRequestGeneration++; } catch (Throwable t) { // the request never completed, so it is not an attempt the user spent Log.w(TAG, "Failed to request the POST_NOTIFICATIONS permission", t); diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java index d68f0e72453..f10ee52124d 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1SurfaceStore.java @@ -58,8 +58,9 @@ public final class CN1SurfaceStore { private static final String KEY_FETCH_AT_PREFIX = "bgFetchAt_"; private static final String KEY_NOTIFICATION_PROMPTS = "notificationPrompts"; private static final String KEY_PROMPTS_INSTALL = "notificationPromptsInstall"; - /// Cached `firstInstallTime`; constant for the life of the process. - private static volatile long installStamp; + /// Cached `firstInstallTime`; constant for the life of the process. Named apart from the + /// `installStamp(Context)` helper that fills it so the two cannot be misread for each other. + private static volatile long cachedInstallStamp; private CN1SurfaceStore() { } @@ -285,7 +286,7 @@ public static void clearNotificationPrompts(Context ctx) { /// must not hand back a spent budget -- but changes on a genuine reinstall and on a restore /// to another device, which is precisely the line this needs to draw. private static long installStamp(Context ctx) { - long stamp = installStamp; + long stamp = cachedInstallStamp; if (stamp != 0) { return stamp; } @@ -299,7 +300,7 @@ private static long installStamp(Context ctx) { // rest of the process and quietly hand every count back as zero. return 0; } - installStamp = stamp; + cachedInstallStamp = stamp; return stamp; } From 7cef8cf0305d9749634b614f13c57fbfe5304d21 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:47:53 +0700 Subject: [PATCH 10/17] Address review round 9: publish the request handoff atomically Codex: the generation was bumped inside PERMISSION_LOCK but published before the winner released it, so a caller sampling in that window saw the new value, went on to block, and then found nothing changed by its own reckoning -- bypassing the adopt branch and opening a second dialog for the same burst. The counter on its own can never express this: a caller needs to know a request was open when it arrived, which is a fact about the arrival instant, not about a delta. Move the bookkeeping under its own STATE_LOCK and publish the counter and the in-flight flag together, so an arriving caller sees either (open, N) or (closed, N+1) and never a torn pair. Arrival snapshots both; admission adopts when either says the burst was already answered. STATE_LOCK is held for a few statements and never across the dialog, and arriving callers release it before contending for PERMISSION_LOCK, so there is no hold-and-wait. The counter still moves only for an answered request, now via an explicit answered flag rather than a bare increment, so the throw path leaves waiters free to ask instead of adopting an outcome that never happened. android module compiles with SpotBugs at zero findings; core-unittests Surface* 51/51. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/CN1LiveActivityManager.java | 88 +++++++++++++------ 1 file changed, 60 insertions(+), 28 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java index 4d8d2720caa..9a5945d618f 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -64,18 +64,21 @@ public final class CN1LiveActivityManager { private static final int MAX_NOTIFICATION_PROMPTS = 2; /// Guards the permission request so concurrent starts raise one dialog and count one answer. private static final Object PERMISSION_LOCK = new Object(); + /// Guards the request bookkeeping. Only ever held for a few statements, never across the + /// dialog, and always taken after `PERMISSION_LOCK` when both are held -- arriving callers + /// take it alone and release it before contending, so there is no hold-and-wait. + private static final Object STATE_LOCK = new Object(); private static final int DECLARED_PRESENT = 1; private static final int DECLARED_MISSING = 2; /// Cached manifest verdict: 0 not looked up yet, otherwise one of the DECLARED_ constants. private static volatile int permissionDeclaredState; - /// True while `checkForPermission` is blocked on the system dialog. Read and written only - /// under `PERMISSION_LOCK`, which is what publishes it; it exists because that monitor is - /// reentrant and `invokeAndBlock` pumps the EDT underneath it. + /// Published together under `STATE_LOCK`, never under `PERMISSION_LOCK` alone: an arriving + /// caller has to see a completing request as one event, or it can miss the handoff. The flag + /// is true while `checkForPermission` blocks on the system dialog -- needed because + /// `PERMISSION_LOCK` is reentrant and `invokeAndBlock` pumps the EDT underneath it -- and the + /// counter moves once per answered request. private static boolean permissionRequestInFlight; - /// Incremented under `PERMISSION_LOCK` each time a request returns an answer. Volatile - /// because callers sample it before contending for the lock, which is how one that waited - /// recognises that the burst it belongs to has already been answered. - private static volatile long permissionRequestGeneration; + private static long permissionRequestGeneration; private static volatile boolean loggedMissingManifest; private static volatile boolean loggedBudgetExhausted; @@ -240,9 +243,18 @@ private static boolean ensureNotificationPermission(Context ctx) { // spending the whole budget on one answer. One request at a time; whoever waited re-reads // the state the winner produced. // - // Sampled before the lock so that a caller which then waits can tell a request completed - // underneath it; see the generation check below. - long seenGeneration = permissionRequestGeneration; + // Snapshot both facts atomically with respect to a completing request: the generation + // alone is not enough, because it is published while the winner still holds + // PERMISSION_LOCK, so a caller sampling in that window would see the new value, wait, and + // then find nothing had changed by its own reckoning. Capturing "a request was open when + // I arrived" closes it -- STATE_LOCK publishes the counter and the flag together, so an + // arriving caller sees either (open, N) or (closed, N+1), never a torn pair. + long seenGeneration; + boolean requestOpenOnArrival; + synchronized (STATE_LOCK) { + seenGeneration = permissionRequestGeneration; + requestOpenOnArrival = permissionRequestInFlight; + } // This serializes surfaces against itself only. A camera or location request in flight // elsewhere still shares that same activity-wide flag and request code, and its callback // can release this one early -- an existing limitation of the shared permission machinery @@ -262,18 +274,26 @@ private static boolean ensureNotificationPermission(Context ctx) { // walk straight through and raise a second dialog. Refuse instead: the nested start // cannot wait for the outer one without deadlocking itself, and an inert handle is // exactly what a refused start is documented to return. - if (permissionRequestInFlight) { - Log.w(TAG, "Ignoring a live activity start raised while the POST_NOTIFICATIONS " - + "prompt is still open; wait for the first start to return before " - + "starting another."); - return false; + boolean answeredWhileWaiting; + synchronized (STATE_LOCK) { + if (permissionRequestInFlight) { + // reached the monitor with a dialog still open, which only a reentrant call + // on the prompting thread can do + Log.w(TAG, "Ignoring a live activity start raised while the " + + "POST_NOTIFICATIONS prompt is still open; wait for the first start " + + "to return before starting another."); + return false; + } + answeredWhileWaiting = requestOpenOnArrival + || permissionRequestGeneration != seenGeneration; } - if (permissionRequestGeneration != seenGeneration) { - // A request finished while this caller was blocked on the lock, so it belongs to - // that same burst and adopts the answer rather than asking again. Serializing - // alone was not enough: the waiter would find the budget merely decremented and - // open a second dialog back to back, spending the whole budget on one burst of - // starts. A start issued later, with no request in between, still gets its turn. + if (answeredWhileWaiting) { + // A request was open when this caller arrived, or finished while it was blocked + // on the lock: either way it belongs to that same burst and adopts the answer + // rather than asking again. Serializing alone was not enough -- the waiter would + // find the budget merely decremented and open a second dialog back to back, + // spending the whole budget on one burst of starts. A start issued later, with no + // request open or completing in between, still gets its own turn. return hasPostNotificationsPermission(ctx); } // Manifest first, matching the precedence in `isSupported`. A build that never @@ -299,21 +319,33 @@ private static boolean ensureNotificationPermission(Context ctx) { + "live activity while the app is visible."); return false; } - boolean granted; - permissionRequestInFlight = true; + synchronized (STATE_LOCK) { + permissionRequestInFlight = true; + } + boolean granted = false; + boolean answered = false; try { granted = AndroidImplementation.checkForPermission( "android.permission.POST_NOTIFICATIONS", "This is required to show live activities", true); - // bumped only once an answer actually came back, so a throw leaves waiters free - // to ask rather than adopting an outcome that never happened - permissionRequestGeneration++; + answered = true; } catch (Throwable t) { // the request never completed, so it is not an attempt the user spent Log.w(TAG, "Failed to request the POST_NOTIFICATIONS permission", t); - return false; } finally { - permissionRequestInFlight = false; + synchronized (STATE_LOCK) { + // counter and flag drop together, so no caller can observe a completed + // request that still looks open, or an open one that already counted. The + // generation moves only for an answer, so a throw leaves waiters free to ask + // rather than adopting an outcome that never happened. + if (answered) { + permissionRequestGeneration++; + } + permissionRequestInFlight = false; + } + } + if (!answered) { + return false; } if (granted) { CN1SurfaceStore.clearNotificationPrompts(ctx); From 2703034aca8a8931f25706f51de4153562c450c1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:54:00 +0700 Subject: [PATCH 11/17] Address review round 10: hold the request open across the monitor handoff Codex: round 9 made the state publication atomic but still did it inside PERMISSION_LOCK, so the window simply moved. A caller arriving between the publication and the lock release snapshots the settled pair, blocks, and is then admitted having observed no change by either measure -- and prompts again right after a denial. Clear the in-flight flag in an outer finally, after the monitor is released, rather than inside it. "Open" then spans the entire time the monitor is held plus the instant after it drops, so every caller arriving in that range sees an open request and adopts. The cost is that a genuinely later start landing in that sliver adopts as well; that errs toward one dialog, which is the right way to be wrong here. That makes the flag observable while another thread holds it, so reentrancy can no longer be inferred from the flag alone: track the owning thread and refuse only a nested call on the prompting thread. A different thread seeing the flag was admitted during the winner's tail and adopts, like every other member of the burst. android module compiles with SpotBugs at zero findings; core-unittests Surface* 51/51. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/CN1LiveActivityManager.java | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java index 9a5945d618f..69de3931253 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -78,6 +78,10 @@ public final class CN1LiveActivityManager { /// `PERMISSION_LOCK` is reentrant and `invokeAndBlock` pumps the EDT underneath it -- and the /// counter moves once per answered request. private static boolean permissionRequestInFlight; + /// Which thread owns the open request, so a reentrant call on the prompting thread can be + /// told apart from a caller admitted during that request's tail: the first must be refused, + /// the second adopts the answer. + private static Thread permissionRequestThread; private static long permissionRequestGeneration; private static volatile boolean loggedMissingManifest; private static volatile boolean loggedBudgetExhausted; @@ -262,6 +266,16 @@ private static boolean ensureNotificationPermission(Context ctx) { // `AndroidImplementation` to fix properly. The damage here is bounded: a spuriously // counted attempt costs the user one of two prompts, and any later grant, from any // source, clears the count. + // The flag is cleared in the outer finally, after PERMISSION_LOCK is released, not + // inside it. Publishing the completed state while still holding the monitor left a window + // where a caller could snapshot the settled pair, block, and then be admitted having + // observed no change at all -- so it would prompt again straight after a denial. Holding + // "open" across the handoff means every caller that arrives while the monitor is held, or + // in the instant after it is dropped, sees an open request and adopts. The cost is that a + // genuinely later start landing in that sliver adopts too; that errs toward one dialog, + // which is the right way to be wrong. + boolean ranRequest = false; + try { synchronized (PERMISSION_LOCK) { if (hasPostNotificationsPermission(ctx)) { CN1SurfaceStore.clearNotificationPrompts(ctx); @@ -276,16 +290,21 @@ private static boolean ensureNotificationPermission(Context ctx) { // exactly what a refused start is documented to return. boolean answeredWhileWaiting; synchronized (STATE_LOCK) { - if (permissionRequestInFlight) { - // reached the monitor with a dialog still open, which only a reentrant call - // on the prompting thread can do + if (permissionRequestInFlight + && permissionRequestThread == Thread.currentThread()) { + // holding the monitor with our own dialog still open: only a reentrant call + // on the prompting thread can be here Log.w(TAG, "Ignoring a live activity start raised while the " + "POST_NOTIFICATIONS prompt is still open; wait for the first start " + "to return before starting another."); return false; } + // A flag still set by *another* thread means we were admitted during its tail -- + // it released the monitor but has not cleared the flag yet -- which is the same + // burst and adopts, exactly like the two arrival facts. answeredWhileWaiting = requestOpenOnArrival - || permissionRequestGeneration != seenGeneration; + || permissionRequestGeneration != seenGeneration + || permissionRequestInFlight; } if (answeredWhileWaiting) { // A request was open when this caller arrived, or finished while it was blocked @@ -321,7 +340,9 @@ private static boolean ensureNotificationPermission(Context ctx) { } synchronized (STATE_LOCK) { permissionRequestInFlight = true; + permissionRequestThread = Thread.currentThread(); } + ranRequest = true; boolean granted = false; boolean answered = false; try { @@ -334,14 +355,12 @@ private static boolean ensureNotificationPermission(Context ctx) { Log.w(TAG, "Failed to request the POST_NOTIFICATIONS permission", t); } finally { synchronized (STATE_LOCK) { - // counter and flag drop together, so no caller can observe a completed - // request that still looks open, or an open one that already counted. The - // generation moves only for an answer, so a throw leaves waiters free to ask - // rather than adopting an outcome that never happened. + // The generation moves only for an answer, so a throw leaves waiters free to + // ask rather than adopting an outcome that never happened. The in-flight flag + // is deliberately NOT cleared here -- see the outer finally. if (answered) { permissionRequestGeneration++; } - permissionRequestInFlight = false; } } if (!answered) { @@ -359,6 +378,14 @@ private static boolean ensureNotificationPermission(Context ctx) { + " of " + MAX_NOTIFICATION_PROMPTS + ")."); return false; } + } finally { + if (ranRequest) { + synchronized (STATE_LOCK) { + permissionRequestInFlight = false; + permissionRequestThread = null; + } + } + } } /// The two settled reasons live activities can be unavailable, each reported once per From ec842d57076d0d7bf08833f3f056618fd7e8b07b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:59:55 +0700 Subject: [PATCH 12/17] Address review round 11: let the platform own the prompt for legacy targets Codex: an app that overrides android.targetSDKVersion down to 32 or lower still runs on Android 13+ devices, and there the platform owns the POST_NOTIFICATIONS prompt -- it appears when the first notification channel is created, not when an app asks for it. Requesting from ensureNotificationPermission therefore returned ungranted every time without showing anything, spent both attempts on requests the user never saw, and left isSupported false for good, all before notifyActivity ever reached ensureChannel to trigger the real prompt. Branch on the app's target SDK. Below 33, ensureNotificationPermission lets the start through without requesting so the channel gets created and the platform asks, and isSupported treats areNotificationsEnabled as the entire answer -- false there means the system already asked and the user declined, and none of the budget applies because such an app never spends it. android module compiles with SpotBugs at zero findings; core-unittests Surface* 51/51. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/CN1LiveActivityManager.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java index 69de3931253..3bc7f344a2a 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -118,6 +118,14 @@ public static boolean isSupported(Context ctx) { // disabled notifications are a settled user choice, not a pending prompt return false; } + if (targetSdkVersion(ctx) < 33) { + // Legacy target on a modern device: the platform owns this prompt and raises it + // when the first notification channel is created, not when an app asks. So + // areNotificationsEnabled is the whole answer -- false here means the system + // already asked and the user declined. None of the budget below applies, because + // this app never gets to spend it. + return false; + } // From API 33 notifications also read as disabled while POST_NOTIFICATIONS is merely // ungranted, which is the state of every fresh install. if (hasPostNotificationsPermission(ctx)) { @@ -241,6 +249,15 @@ private static boolean ensureNotificationPermission(Context ctx) { CN1SurfaceStore.clearNotificationPrompts(ctx); return true; } + if (targetSdkVersion(ctx) < 33) { + // Legacy target on a modern device: requesting cannot raise a dialog, because the + // platform shows this one on first notification-channel creation instead -- which + // notifyActivity is about to do. Asking anyway would return ungranted every time and + // spend the whole budget on requests the user never saw, leaving isSupported false + // for good without the channel that would have prompted them. Let the start through + // and let the platform ask. + return true; + } // `start` is callable from any thread and `checkForPermission` drives the activity's one // shared request flag and request code, so two concurrent starts would otherwise raise a // single dialog whose single outcome released both callers -- and then be counted twice, @@ -464,6 +481,17 @@ private static boolean isPermissionMissing(Context ctx) { } } + /// The app's own target SDK, which decides who owns the notification prompt. Falls back to + /// the device level, i.e. the modern path, when it cannot be read -- the same answer every + /// build produces unless it overrides the android.targetSDKVersion hint downward. + private static int targetSdkVersion(Context ctx) { + try { + return ctx.getApplicationInfo().targetSdkVersion; + } catch (Throwable t) { + return Build.VERSION.SDK_INT; + } + } + private static boolean hasPostNotificationsPermission(Context ctx) { try { return ctx.getPackageManager().checkPermission("android.permission.POST_NOTIFICATIONS", From ba24fe660e04b68e412a564a49c323b41c5b2167 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:06:59 +0700 Subject: [PATCH 13/17] Address review round 12: do not let the capability check block the platform prompt Codex: the legacy-target bypass added in round 11 is unreachable through the public API. LiveActivity.start preflights isLiveActivitySupported, and round 11 had isSupported return false for a legacy target whenever notifications read as disabled -- which on a fresh install is exactly the state before the platform has asked anything. So start handed back an inert handle, notifyActivity never reached ensureChannel, and the channel creation that triggers the platform's own prompt could never happen. This is the round 6 mistake repeated: a settled "no" placed in isSupported for a state that is not settled, blocking the path that would have resolved it. For a legacy target, stay supported until a notification channel exists to show the user was actually asked; once one does, disabled means declined. An unreadable channel list counts as "not asked yet", which errs toward letting the prompt happen. android module compiles with SpotBugs at zero findings; core-unittests Surface* 51/51. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/CN1LiveActivityManager.java | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java index 3bc7f344a2a..762f5682396 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -120,11 +120,14 @@ public static boolean isSupported(Context ctx) { } if (targetSdkVersion(ctx) < 33) { // Legacy target on a modern device: the platform owns this prompt and raises it - // when the first notification channel is created, not when an app asks. So - // areNotificationsEnabled is the whole answer -- false here means the system - // already asked and the user declined. None of the budget below applies, because - // this app never gets to spend it. - return false; + // when the app creates its first notification channel, not when an app asks. On a + // fresh install nothing has been asked yet, so notifications read as disabled for + // want of a question -- and returning false here would hand back an inert handle + // from LiveActivity.start, so notifyActivity would never reach ensureChannel and + // the prompt could never appear at all. Stay supported until a channel exists to + // show the user was actually asked; once one does, disabled means declined. None + // of the budget below applies either way, since such an app never spends it. + return !hasNotificationChannels(nm); } // From API 33 notifications also read as disabled while POST_NOTIFICATIONS is merely // ungranted, which is the state of every fresh install. @@ -481,6 +484,18 @@ private static boolean isPermissionMissing(Context ctx) { } } + /// True once the app owns at least one notification channel, which for a legacy target is + /// the evidence that the platform has had its chance to raise the prompt. An unreadable + /// answer counts as "no channels yet", erring toward letting the prompt happen. + private static boolean hasNotificationChannels(NotificationManager nm) { + try { + java.util.List channels = nm.getNotificationChannels(); + return channels != null && !channels.isEmpty(); + } catch (Throwable t) { + return false; + } + } + /// The app's own target SDK, which decides who owns the notification prompt. Falls back to /// the device level, i.e. the modern path, when it cannot be read -- the same answer every /// build produces unless it overrides the android.targetSDKVersion hint downward. From 349030dba41d360b328cd5bf852a22b38de77478 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:26:45 +0700 Subject: [PATCH 14/17] Pre-grant POST_NOTIFICATIONS in the Android instrumentation suite The Android legs of this PR failed deterministically -- three jobs, twice each -- with DesktopMode, Media360Panorama, VRStereoScene and VideoIODecodedFrames producing no screenshot, sixteen tests never running, and no completion marker. The same four pass on master. Root cause is this branch. SurfacesPublishTest does: if (LiveActivity.isSupported()) { LiveActivity.start(...); } Before this branch isSupported() returned false on an emulator, because POST_NOTIFICATIONS is ungranted and areNotificationsEnabled() reads false, so the start was skipped. Making that case report supported is the entire point of the fix -- and it means the suite now reaches start(), which asks for the permission. On an unattended emulator nobody answers: the requesting thread waits on the dialog indefinitely and the modal sits on top of every screenshot that follows, so the suite stops producing output partway through. Grant the permission from the instrumentation entry point before the app is launched, which is what an unattended suite has to do and what a device user does once by hand. The suite then exercises the granted live-activity path rather than the prompt. The grant is best-effort: below API 33 the permission does not exist and pm grant rejects it, which must not fail the run. Product behaviour is deliberately unchanged. Blocking the caller until the user answers is what every other Codename One permission does through AndroidImplementation.checkForPermission -- camera, location, contacts -- and what LiveActivity.start already documents. Co-Authored-By: Claude Opus 5 (1M context) --- .../DeviceRunnerInstrumentationTest.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/scripts/device-runner-app/androidTest/DeviceRunnerInstrumentationTest.java b/scripts/device-runner-app/androidTest/DeviceRunnerInstrumentationTest.java index e510d7975f2..91dfc9ae0e6 100644 --- a/scripts/device-runner-app/androidTest/DeviceRunnerInstrumentationTest.java +++ b/scripts/device-runner-app/androidTest/DeviceRunnerInstrumentationTest.java @@ -26,6 +26,7 @@ public class DeviceRunnerInstrumentationTest { @Test public void launchMainActivityAndWaitForDeviceRunner() throws Exception { Context context = ApplicationProvider.getApplicationContext(); + grantNotificationPermission(context.getPackageName()); Intent intent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()); assertNotNull("Launch intent not found for package " + context.getPackageName(), intent); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); @@ -37,6 +38,38 @@ public void launchMainActivityAndWaitForDeviceRunner() throws Exception { } } + /// Pre-grants POST_NOTIFICATIONS, the way an unattended instrumentation suite has to. + /// + /// The suite's SurfacesPublishTest starts a live activity, which on Android 13+ lowers to an + /// ongoing notification and therefore needs this permission. Without the grant the port asks + /// for it, the system dialog opens over the app, and nobody is there to answer: the requesting + /// thread waits on it indefinitely and the modal sits on top of every screenshot that follows, + /// so the suite stops emitting output partway through and never reaches its completion marker. + /// Granting up front is what a device user does once by hand, and it lets the suite exercise + /// the granted path rather than the prompt. + /// + /// Failures are logged and ignored: below API 33 the permission does not exist and `pm grant` + /// rejects it, which is correct and must not fail the run. + private void grantNotificationPermission(String packageName) { + String command = "pm grant " + packageName + " android.permission.POST_NOTIFICATIONS"; + try { + UiAutomation automation = InstrumentationRegistry.getInstrumentation().getUiAutomation(); + ParcelFileDescriptor pfd = automation.executeShellCommand(command); + try (FileInputStream fis = new FileInputStream(pfd.getFileDescriptor())) { + // draining the pipe is what lets the command run to completion + byte[] buffer = new byte[256]; + while (fis.read(buffer) > 0) { + // discard + } + } finally { + pfd.close(); + } + Log.i(TAG, "Granted POST_NOTIFICATIONS to " + packageName); + } catch (Throwable t) { + Log.w(TAG, "Could not grant POST_NOTIFICATIONS (expected below API 33): " + t); + } + } + private boolean waitForDeviceRunner() throws Exception { final long timeoutMs = 900_000L; final String endMarker = "CN1SS:SUITE:FINISHED"; From ab56329538fdced5051efa23f1935b2df1ae2680 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:29:15 +0700 Subject: [PATCH 15/17] Add the missing license header to the device runner instrumentation test check-copyright-headers validates added and modified files, and this one predates the gate without a header, so touching it in the previous commit tripped the check. Add the Codename One GPLv2 + Classpath Exception header the script expects; verified locally with scripts/check-copyright-headers.sh. Co-Authored-By: Claude Opus 5 (1M context) --- .../DeviceRunnerInstrumentationTest.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/device-runner-app/androidTest/DeviceRunnerInstrumentationTest.java b/scripts/device-runner-app/androidTest/DeviceRunnerInstrumentationTest.java index 91dfc9ae0e6..8b6d125c5ea 100644 --- a/scripts/device-runner-app/androidTest/DeviceRunnerInstrumentationTest.java +++ b/scripts/device-runner-app/androidTest/DeviceRunnerInstrumentationTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codenameone.examples.hellocodenameone; import android.app.UiAutomation; From a64c5afceb15da044e4245d02b52370dd281fe3b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 04:10:46 +0700 Subject: [PATCH 16/17] Address review round 13: check the manifest before the legacy bypass, stop guessing at the prompt Two findings on the legacy-target branch from round 11, both correct. Codex: the bypass returned true before isPermissionMissing ran, so an app that uses widgets but never set "liveActivities": true got an active handle for a notification Android would not post, and isSupported then settled on false down its own legacy path without ever emitting the missing-manifest diagnosis. The declaration check now comes first. Codex: returning true also meant notifyActivity posted immediately. The platform's prompt is asynchronous -- it is tied to the next activity start, not to channel creation returning -- so a fresh install was still ungranted, nothing appeared, and start nevertheless handed back a handle reporting isActive() with no repost once the user allowed. Create the channel so the platform has its trigger, re-check, and refuse this start when the permission has not arrived; the next start posts for real. isSupported's legacy branch stops guessing too. It inferred "the user was asked" from a channel existing, which does not follow, and Android exposes nothing that separates "not asked yet" from "asked and declined" -- areNotificationsEnabled is false for both. It now reports the one knowable thing, whether the permission was declared, so a legacy app keeps retrying rather than being locked out by a wrong guess. hasNotificationChannels was the only caller of that inference and is deleted rather than left as dead code. This branch has now been wrong in three consecutive rounds, in three different directions, and it cannot be exercised here: it needs an Android 13+ device running a deliberately down-targeted build. It is reasoned from documented behaviour, not observed, and deserves a device before release more than anything else in this change. android module compiles with SpotBugs at zero findings; core-unittests Surface* 51/51. Co-Authored-By: Claude Opus 5 (1M context) --- .../surfaces/CN1LiveActivityManager.java | 61 +++++++++++-------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java index 762f5682396..aeefcd3102b 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -120,14 +120,14 @@ public static boolean isSupported(Context ctx) { } if (targetSdkVersion(ctx) < 33) { // Legacy target on a modern device: the platform owns this prompt and raises it - // when the app creates its first notification channel, not when an app asks. On a - // fresh install nothing has been asked yet, so notifications read as disabled for - // want of a question -- and returning false here would hand back an inert handle - // from LiveActivity.start, so notifyActivity would never reach ensureChannel and - // the prompt could never appear at all. Stay supported until a channel exists to - // show the user was actually asked; once one does, disabled means declined. None - // of the budget below applies either way, since such an app never spends it. - return !hasNotificationChannels(nm); + // around the app's first notification channel, not when an app asks. Android + // exposes nothing that separates "not asked yet" from "asked and declined" here -- + // areNotificationsEnabled is false either way, and a created channel does not + // prove the prompt was shown, since the platform ties it to the next activity + // start. Rather than guess, report on the one thing that is knowable: whether the + // permission was declared at all. That keeps a legacy app retrying instead of + // being locked out by a wrong guess, and the budget below never applies to it. + return !isPermissionMissing(ctx); } // From API 33 notifications also read as disabled while POST_NOTIFICATIONS is merely // ungranted, which is the state of every fresh install. @@ -254,12 +254,33 @@ private static boolean ensureNotificationPermission(Context ctx) { } if (targetSdkVersion(ctx) < 33) { // Legacy target on a modern device: requesting cannot raise a dialog, because the - // platform shows this one on first notification-channel creation instead -- which - // notifyActivity is about to do. Asking anyway would return ungranted every time and - // spend the whole budget on requests the user never saw, leaving isSupported false - // for good without the channel that would have prompted them. Let the start through - // and let the platform ask. - return true; + // platform shows this one around the app's first notification channel instead. The + // manifest still has to declare the permission, and that check has to come first -- + // otherwise an app that never set "liveActivities": true sails past it here and gets + // an active handle for a notification Android will not post. + if (isPermissionMissing(ctx)) { + logMissingManifestOnce(); + return false; + } + // Create the channel now so the platform has its trigger, then re-check. The prompt + // is asynchronous -- the system ties it to the next activity start -- so a fresh + // install is still ungranted on the way out of here, and posting anyway would hand + // back a live handle for a notification that never appeared and is never reposted + // once the user does allow it. Refuse this start; the next one, after the grant, + // posts for real. + NotificationManager nm = + (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm != null) { + ensureChannel(ctx, nm, DEFAULT_CHANNEL); + } + if (hasPostNotificationsPermission(ctx)) { + return true; + } + Log.w(TAG, "Live activities are not available yet: this build targets an SDK below " + + "33, so Android raises the notification prompt itself around the app's " + + "first notification channel. The channel now exists; start the activity " + + "again once the user has allowed notifications."); + return false; } // `start` is callable from any thread and `checkForPermission` drives the activity's one // shared request flag and request code, so two concurrent starts would otherwise raise a @@ -484,18 +505,6 @@ private static boolean isPermissionMissing(Context ctx) { } } - /// True once the app owns at least one notification channel, which for a legacy target is - /// the evidence that the platform has had its chance to raise the prompt. An unreadable - /// answer counts as "no channels yet", erring toward letting the prompt happen. - private static boolean hasNotificationChannels(NotificationManager nm) { - try { - java.util.List channels = nm.getNotificationChannels(); - return channels != null && !channels.isEmpty(); - } catch (Throwable t) { - return false; - } - } - /// The app's own target SDK, which decides who owns the notification prompt. Falls back to /// the device level, i.e. the modern path, when it cannot be read -- the same answer every /// build produces unless it overrides the android.targetSDKVersion hint downward. From 7fc392cabae44e4eeb3f0d269d7f10ff7c9877f9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:50:21 +0700 Subject: [PATCH 17/17] Address review round 14: gate the legacy channel creation on the foreground too Codex: the legacy-target branch created the notification channel without the foreground check the modern path puts in front of its request. Creating that channel is precisely what arms the platform-owned prompt, and Android defers the prompt to the next activity launch -- so a background push or service reaching this path would ambush the user with a notification dialog the next time they opened the app, for a live activity they never saw asked for, and the start was refused anyway. Apply the same foreground gate before creating the channel. android module compiles with SpotBugs at zero findings; core-unittests Surface* 51/51. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/surfaces/CN1LiveActivityManager.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java index aeefcd3102b..d21c4426f87 100644 --- a/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java +++ b/Ports/Android/src/com/codename1/impl/android/surfaces/CN1LiveActivityManager.java @@ -262,6 +262,18 @@ private static boolean ensureNotificationPermission(Context ctx) { logMissingManifestOnce(); return false; } + // Creating the channel is what arms the platform's prompt, so it needs the same + // foreground gate the modern path puts in front of its request. Android defers this + // prompt to the next activity launch, so arming it from a background push or service + // would ambush the user with a notification dialog the next time they opened the app, + // for a live activity they never saw asked for. + if (!hasForegroundActivity()) { + Log.w(TAG, "Cannot start a live activity: this build targets an SDK below 33, so " + + "the notification prompt is armed by creating a channel, and doing that " + + "from the background would prompt the user at their next app launch. " + + "Start the first live activity while the app is visible."); + return false; + } // Create the channel now so the platform has its trigger, then re-check. The prompt // is asynchronous -- the system ties it to the next activity start -- so a fresh // install is still ungranted on the way out of here, and posting anyway would hand