diff --git a/.github/scripts/generate-quality-report.py b/.github/scripts/generate-quality-report.py index 7cf7ce0122..be1fa65eb2 100755 --- a/.github/scripts/generate-quality-report.py +++ b/.github/scripts/generate-quality-report.py @@ -900,6 +900,9 @@ def main() -> None: "NP_BOOLEAN_RETURN_NULL", "RC_REF_COMPARISON_BAD_PRACTICE_BOOLEAN", "OS_OPEN_STREAM", + "OS_OPEN_STREAM_EXCEPTION_PATH", + "OBL_UNSATISFIED_OBLIGATION_EXCEPTION_EDGE", + "RV_RETURN_VALUE_IGNORED_BAD_PRACTICE", "REFLC_REFLECTION_MAY_INCREASE_ACCESSIBILITY_OF_CLASS", "REC_CATCH_EXCEPTION", "RCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE", diff --git a/CodenameOne/src/com/codename1/background/BackgroundTask.java b/CodenameOne/src/com/codename1/background/BackgroundTask.java new file mode 100644 index 0000000000..24f72b83a2 --- /dev/null +++ b/CodenameOne/src/com/codename1/background/BackgroundTask.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.background; + +import com.codename1.ui.Display; +import java.util.Date; + +/// Schedules a one-shot deferrable processing task that the operating system runs at a +/// convenient time after an earliest-begin date. On iOS this maps to a +/// `BGProcessingTaskRequest`; on Android it maps to a one-shot WorkManager request with an +/// initial delay; in the simulator it runs on a timer. +/// +/// The `Runnable` passed here runs in the foreground simulator and during a live app +/// session. On iOS, after the app process has been killed, the registered task identifier +/// is relaunched by the OS and the work is reconstructed from the persisted schedule; for +/// that cold-launch path prefer `BackgroundWork` with a `BackgroundWorker` class, which is +/// reconstructed via its no-arg constructor. +/// +/// #### See also +/// +/// - BackgroundWork +public final class BackgroundTask { + + private BackgroundTask() { + } + + /// Schedules a processing task. + /// + /// #### Parameters + /// + /// - `id`: a stable unique id for the task; it must be declared in the build hint listing permitted background task identifiers on iOS + /// + /// - `earliestBeginDate`: the earliest date at which the task may run, or null for as soon as convenient + /// + /// - `requiresNetwork`: true if the task needs network connectivity + /// + /// - `requiresPower`: true if the task needs the device to be charging + /// + /// - `task`: the work to run + public static void scheduleProcessing(String id, Date earliestBeginDate, boolean requiresNetwork, boolean requiresPower, Runnable task) { + long earliest = earliestBeginDate == null ? 0 : earliestBeginDate.getTime(); + Display.getInstance().scheduleBackgroundProcessing(id, earliest, requiresNetwork, requiresPower, task); + } + + /// Cancels a previously scheduled processing task. + /// + /// #### Parameters + /// + /// - `id`: the task id + public static void cancel(String id) { + Display.getInstance().cancelBackgroundProcessing(id); + } + + /// Returns true if the current platform supports deferrable background processing. + /// + /// #### Returns + /// + /// true if background processing is supported + public static boolean isSupported() { + return Display.getInstance().isBackgroundProcessingSupported(); + } +} diff --git a/CodenameOne/src/com/codename1/background/BackgroundWork.java b/CodenameOne/src/com/codename1/background/BackgroundWork.java new file mode 100644 index 0000000000..0688e7485b --- /dev/null +++ b/CodenameOne/src/com/codename1/background/BackgroundWork.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.background; + +import com.codename1.ui.Display; + +/// Entry point for scheduling constraint-aware background work. On Android this maps to +/// WorkManager and on iOS to `BGTaskScheduler`. In the simulator the work runs on a timer +/// and honors the constraint toggles in the Simulate menu. +/// +/// #### See also +/// +/// - WorkRequest +/// +/// - BackgroundWorker +public final class BackgroundWork { + + private BackgroundWork() { + } + + /// Schedules a unit of background work. If a request with the same id was previously + /// scheduled it is replaced. On platforms that do not support background work this is + /// a no-op; check `#isSupported()` first. + /// + /// #### Parameters + /// + /// - `request`: the work request to schedule + public static void schedule(WorkRequest request) { + Display.getInstance().scheduleBackgroundWork(request); + } + + /// Cancels previously scheduled work by id. + /// + /// #### Parameters + /// + /// - `workId`: the id of the work to cancel + public static void cancel(String workId) { + Display.getInstance().cancelBackgroundWork(workId); + } + + /// Returns true if the current platform supports constraint-aware background work. + /// + /// #### Returns + /// + /// true if background work is supported + public static boolean isSupported() { + return Display.getInstance().isBackgroundWorkSupported(); + } +} diff --git a/CodenameOne/src/com/codename1/background/BackgroundWorker.java b/CodenameOne/src/com/codename1/background/BackgroundWorker.java new file mode 100644 index 0000000000..7ed0328be3 --- /dev/null +++ b/CodenameOne/src/com/codename1/background/BackgroundWorker.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.background; + +import com.codename1.util.Callback; +import java.util.Map; + +/// Implemented by an application class to perform constraint-aware background work +/// scheduled through `BackgroundWork#schedule(WorkRequest)`. +/// +/// The implementing class MUST have a public no-argument constructor: the platform may +/// reconstruct a fresh instance after the app process has been killed and cold launched +/// to run the work, so no state from the foreground app is available. Pass any required +/// state through the work request input data. +/// +/// #### See also +/// +/// - BackgroundWork +/// +/// - WorkRequest +public interface BackgroundWorker { + + /// Performs the background work. The implementation should call `onComplete` with + /// `Boolean.TRUE` on success or `Boolean.FALSE` to request that the platform retry + /// the work later. The work must finish before `deadline`; if it does not, the + /// platform may terminate it. + /// + /// #### Parameters + /// + /// - `workId`: the id of the work request being executed + /// + /// - `inputData`: the immutable input data supplied to the work request + /// + /// - `deadline`: the time in milliseconds since the epoch by which the work must finish + /// + /// - `onComplete`: callback invoked with the outcome (true for success, false to retry) + void performWork(String workId, Map inputData, long deadline, Callback onComplete); +} diff --git a/CodenameOne/src/com/codename1/background/ForegroundService.java b/CodenameOne/src/com/codename1/background/ForegroundService.java new file mode 100644 index 0000000000..52cf04d8a6 --- /dev/null +++ b/CodenameOne/src/com/codename1/background/ForegroundService.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.background; + +import com.codename1.ui.Display; + +/// Runs a long lived task while a persistent system notification is shown to the user. +/// This is an Android foreground service. On iOS, which has no foreground service concept, +/// the task runs under a limited background execution window accompanied by a local +/// notification (best effort); check `#isSupported()` to detect full support. +/// +/// Usage +/// ```java +/// ForegroundService svc = ForegroundService.start("downloads", "Downloading", "Please wait", null, +/// service -> { +/// for (int i = 0; i <= 100; i++) { +/// service.updateNotification("Downloading", i + "%"); +/// // ... do a chunk of work ... +/// } +/// }); +/// // the service auto-stops when the task returns, or call svc.stop() early +/// ``` +public final class ForegroundService { + + /// The long running task executed by a foreground service. + public interface Task { + + /// Runs the work. The accompanying notification remains visible until this method + /// returns or `ForegroundService#stop()` is called. + /// + /// #### Parameters + /// + /// - `service`: the running service, used to update the notification or stop early + void run(ForegroundService service); + } + + private Object nativeHandle; + private boolean running; + + private ForegroundService() { + } + + /// Starts a foreground service that shows a persistent notification and runs the task + /// on a background thread. + /// + /// #### Parameters + /// + /// - `channelId`: the notification channel id to post the notification on + /// + /// - `title`: the notification title + /// + /// - `body`: the notification body + /// + /// - `iconName`: the small icon resource name, or null for the default app icon + /// + /// - `task`: the work to run + /// + /// #### Returns + /// + /// the running service handle + public static ForegroundService start(String channelId, String title, String body, String iconName, Task task) { + ForegroundService svc = new ForegroundService(); + svc.nativeHandle = Display.getInstance().startForegroundService(channelId, title, body, iconName, task, svc); + svc.running = true; + return svc; + } + + /// Updates the text of the foreground service notification. + /// + /// #### Parameters + /// + /// - `title`: the new title + /// + /// - `body`: the new body + public void updateNotification(String title, String body) { + if (running) { + Display.getInstance().updateForegroundServiceNotification(nativeHandle, title, body); + } + } + + /// Stops the service and removes its notification. + public void stop() { + if (running) { + running = false; + Display.getInstance().stopForegroundService(nativeHandle); + } + } + + /// Returns true if the service is currently running. + /// + /// #### Returns + /// + /// true if running + public boolean isRunning() { + return running; + } + + /// Returns true if the current platform fully supports foreground services. + /// + /// #### Returns + /// + /// true if foreground services are supported + public static boolean isSupported() { + return Display.getInstance().isForegroundServiceSupported(); + } +} diff --git a/CodenameOne/src/com/codename1/background/WorkRequest.java b/CodenameOne/src/com/codename1/background/WorkRequest.java new file mode 100644 index 0000000000..6181c56a00 --- /dev/null +++ b/CodenameOne/src/com/codename1/background/WorkRequest.java @@ -0,0 +1,340 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.background; + +import java.util.HashMap; +import java.util.Map; + +/// Describes a unit of constraint-aware background work to be scheduled with +/// `BackgroundWork#schedule(WorkRequest)`. The constraints map to Android WorkManager +/// constraints and iOS `BGTaskScheduler` request options. +/// +/// Input data is a string-keyed string map so that it can survive serialization into the +/// platform scheduler and a subsequent cold launch of the app process. +/// +/// Usage +/// ```java +/// WorkRequest req = WorkRequest.builder("sync", SyncWorker.class) +/// .setRequiresNetwork(true) +/// .setRequiresCharging(true) +/// .setPeriodic(6 * 60 * 60 * 1000L) +/// .putInputData("account", "primary") +/// .build(); +/// BackgroundWork.schedule(req); +/// ``` +/// +/// #### See also +/// +/// - BackgroundWork +/// +/// - BackgroundWorker +public final class WorkRequest { + + private final String id; + private final String workerClass; + private final boolean requiresNetwork; + private final boolean requiresUnmeteredNetwork; + private final boolean requiresCharging; + private final boolean requiresIdle; + private final boolean requiresBatteryNotLow; + private final boolean periodic; + private final long minIntervalMillis; + private final long initialDelayMillis; + private final Map inputData; + + private WorkRequest(Builder b) { + this.id = b.id; + this.workerClass = b.workerClass; + this.requiresNetwork = b.requiresNetwork; + this.requiresUnmeteredNetwork = b.requiresUnmeteredNetwork; + this.requiresCharging = b.requiresCharging; + this.requiresIdle = b.requiresIdle; + this.requiresBatteryNotLow = b.requiresBatteryNotLow; + this.periodic = b.periodic; + this.minIntervalMillis = b.minIntervalMillis; + this.initialDelayMillis = b.initialDelayMillis; + this.inputData = new HashMap(b.inputData); + } + + /// Returns the unique work id. + /// + /// #### Returns + /// + /// the work id + public String getId() { + return id; + } + + /// Returns the fully qualified class name of the `BackgroundWorker` that performs + /// the work. + /// + /// #### Returns + /// + /// the worker class name + public String getWorkerClass() { + return workerClass; + } + + /// Returns true if the work requires any network connection. + /// + /// #### Returns + /// + /// true if a network is required + public boolean isRequiresNetwork() { + return requiresNetwork; + } + + /// Returns true if the work requires an unmetered (for example Wi-Fi) network. + /// + /// #### Returns + /// + /// true if an unmetered network is required + public boolean isRequiresUnmeteredNetwork() { + return requiresUnmeteredNetwork; + } + + /// Returns true if the work requires the device to be charging. + /// + /// #### Returns + /// + /// true if charging is required + public boolean isRequiresCharging() { + return requiresCharging; + } + + /// Returns true if the work requires the device to be idle (Android only). + /// + /// #### Returns + /// + /// true if device idle is required + public boolean isRequiresIdle() { + return requiresIdle; + } + + /// Returns true if the work requires the battery to not be low (Android only). + /// + /// #### Returns + /// + /// true if battery-not-low is required + public boolean isRequiresBatteryNotLow() { + return requiresBatteryNotLow; + } + + /// Returns true if the work repeats periodically. + /// + /// #### Returns + /// + /// true if periodic + public boolean isPeriodic() { + return periodic; + } + + /// Returns the minimum interval between periodic executions in milliseconds, or 0 for + /// one-shot work. + /// + /// #### Returns + /// + /// the minimum periodic interval in milliseconds + public long getMinIntervalMillis() { + return minIntervalMillis; + } + + /// Returns the initial delay before the first execution in milliseconds. + /// + /// #### Returns + /// + /// the initial delay in milliseconds + public long getInitialDelayMillis() { + return initialDelayMillis; + } + + /// Returns an immutable copy of the input data. + /// + /// #### Returns + /// + /// the input data, never null + public Map getInputData() { + return new HashMap(inputData); + } + + /// Creates a new work request builder. + /// + /// #### Parameters + /// + /// - `id`: a stable unique id for the work; scheduling another request with the same id replaces it + /// + /// - `worker`: the worker class that performs the work; it must have a public no-arg constructor + /// + /// #### Returns + /// + /// a new builder + public static Builder builder(String id, Class worker) { + return new Builder(id, worker.getName()); + } + + /// Builder for `WorkRequest`. + public static final class Builder { + private final String id; + private final String workerClass; + private boolean requiresNetwork; + private boolean requiresUnmeteredNetwork; + private boolean requiresCharging; + private boolean requiresIdle; + private boolean requiresBatteryNotLow; + private boolean periodic; + private long minIntervalMillis; + private long initialDelayMillis; + private final Map inputData = new HashMap(); + + Builder(String id, String workerClass) { + this.id = id; + this.workerClass = workerClass; + } + + /// Requires any network connection. + /// + /// #### Parameters + /// + /// - `b`: true to require a network + /// + /// #### Returns + /// + /// this builder for chaining + public Builder setRequiresNetwork(boolean b) { + this.requiresNetwork = b; + return this; + } + + /// Requires an unmetered network connection. + /// + /// #### Parameters + /// + /// - `b`: true to require an unmetered network + /// + /// #### Returns + /// + /// this builder for chaining + public Builder setRequiresUnmeteredNetwork(boolean b) { + this.requiresUnmeteredNetwork = b; + return this; + } + + /// Requires the device to be charging. + /// + /// #### Parameters + /// + /// - `b`: true to require charging + /// + /// #### Returns + /// + /// this builder for chaining + public Builder setRequiresCharging(boolean b) { + this.requiresCharging = b; + return this; + } + + /// Requires the device to be idle (Android only). + /// + /// #### Parameters + /// + /// - `b`: true to require device idle + /// + /// #### Returns + /// + /// this builder for chaining + public Builder setRequiresIdle(boolean b) { + this.requiresIdle = b; + return this; + } + + /// Requires the battery to not be low (Android only). + /// + /// #### Parameters + /// + /// - `b`: true to require battery-not-low + /// + /// #### Returns + /// + /// this builder for chaining + public Builder setRequiresBatteryNotLow(boolean b) { + this.requiresBatteryNotLow = b; + return this; + } + + /// Makes the work periodic with the given minimum interval. Note that platforms + /// enforce a minimum period (for example 15 minutes on Android) and iOS only + /// approximates periodic work by resubmission. + /// + /// #### Parameters + /// + /// - `minIntervalMillis`: the minimum interval between executions in milliseconds + /// + /// #### Returns + /// + /// this builder for chaining + public Builder setPeriodic(long minIntervalMillis) { + this.periodic = true; + this.minIntervalMillis = minIntervalMillis; + return this; + } + + /// Sets an initial delay before the first execution. + /// + /// #### Parameters + /// + /// - `millis`: the delay in milliseconds + /// + /// #### Returns + /// + /// this builder for chaining + public Builder setInitialDelay(long millis) { + this.initialDelayMillis = millis; + return this; + } + + /// Adds a key-value pair to the input data passed to the worker. + /// + /// #### Parameters + /// + /// - `key`: the key + /// + /// - `value`: the value + /// + /// #### Returns + /// + /// this builder for chaining + public Builder putInputData(String key, String value) { + inputData.put(key, value); + return this; + } + + /// Builds the immutable work request. + /// + /// #### Returns + /// + /// the work request + public WorkRequest build() { + return new WorkRequest(this); + } + } +} diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 24cfb68c8d..5810fa4d28 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -53,6 +53,13 @@ import com.codename1.media.MediaRecorderBuilder; import com.codename1.messaging.Message; import com.codename1.notifications.LocalNotification; +import com.codename1.notifications.NotificationChannelBuilder; +import com.codename1.notifications.NotificationPermissionCallback; +import com.codename1.notifications.NotificationPermissionRequest; +import com.codename1.notifications.NotificationPermissionResult; +import com.codename1.background.ForegroundService; +import com.codename1.background.WorkRequest; +import com.codename1.share.SharedContent; import com.codename1.share.ShareResult; import com.codename1.share.ShareResultListener; import com.codename1.payment.Purchase; @@ -181,6 +188,29 @@ public static void setOnExit(Runnable on) { onExit = on; } + private static Object currentApplicationInstance; + + /// Stores the running application's main class instance so the implementation can + /// dispatch lifecycle style callbacks (such as shared content delivery) to it. Set by + /// the platform port when it bootstraps the application. + /// + /// #### Parameters + /// + /// - `app`: the application main class instance + public static void setCurrentApplicationInstance(Object app) { + currentApplicationInstance = app; + } + + /// Returns the running application's main class instance, or null if it has not been + /// captured. + /// + /// #### Returns + /// + /// the application main class instance, or null + public static Object getCurrentApplicationInstance() { + return currentApplicationInstance; + } + /// Allows the system to register to receive push callbacks /// /// #### Parameters @@ -10171,6 +10201,134 @@ public void scheduleLocalNotification(LocalNotification notif, long firstTime, i public void cancelLocalNotification(String notificationId) { } + /// Requests permission to post notifications. The default implementation assumes the + /// platform has no permission model and immediately reports the permission as granted. + public void requestNotificationPermission(NotificationPermissionRequest request, NotificationPermissionCallback callback) { + if (callback != null) { + callback.notificationPermissionResult(new NotificationPermissionResult(NotificationPermissionResult.AuthorizationLevel.AUTHORIZED)); + } + } + + /// Registers a notification channel. No-op on platforms without channels. + public void registerNotificationChannel(NotificationChannelBuilder builder) { + } + + /// Deletes a notification channel. No-op on platforms without channels. + public void deleteNotificationChannel(String channelId) { + } + + /// Creates a notification channel group. No-op on platforms without channels. + public void createNotificationChannelGroup(String groupId, String groupName) { + } + + /// Returns true if the platform can receive shared content from other apps. + public boolean isReceiveSharedContentSupported() { + return false; + } + + /// Delivers shared content to the running application instance. onReceivedSharedContent + /// is defined on com.codename1.system.Lifecycle, so apps that handle shared content + /// extend Lifecycle; non-Lifecycle apps cannot override it and are skipped. The + /// dispatch is performed on the EDT. + public void fireSharedContentReceived(final SharedContent content) { + Object app = currentApplicationInstance; + if (content == null || !(app instanceof com.codename1.system.Lifecycle)) { + return; + } + Runnable r = new SharedContentDispatch((com.codename1.system.Lifecycle) app, content); + if (Display.getInstance().isEdt()) { + r.run(); + } else { + Display.getInstance().callSerially(r); + } + } + + private static final class SharedContentDispatch implements Runnable { + private final com.codename1.system.Lifecycle lifecycle; + private final SharedContent content; + + SharedContentDispatch(com.codename1.system.Lifecycle lifecycle, SharedContent content) { + this.lifecycle = lifecycle; + this.content = content; + } + + @Override + public void run() { + lifecycle.onReceivedSharedContent(content); + } + } + + /// Returns true if the platform supports constraint-aware background work. + public boolean isBackgroundWorkSupported() { + return false; + } + + /// Schedules constraint-aware background work. No-op when unsupported. + public void scheduleBackgroundWork(WorkRequest request) { + } + + /// Cancels previously scheduled background work. No-op when unsupported. + public void cancelBackgroundWork(String workId) { + } + + /// Returns true if the platform supports foreground services. + public boolean isForegroundServiceSupported() { + return false; + } + + /// Starts a foreground service. The default implementation runs the task on a thread + /// without a system notification and returns null. + public Object startForegroundService(String channelId, String title, String body, String iconName, ForegroundService.Task task, ForegroundService handle) { + if (task != null) { + new Thread(new ForegroundServiceRunner(task, handle)).start(); + } + return null; + } + + private static final class ForegroundServiceRunner implements Runnable { + private final ForegroundService.Task task; + private final ForegroundService handle; + + ForegroundServiceRunner(ForegroundService.Task task, ForegroundService handle) { + this.task = task; + this.handle = handle; + } + + @Override + public void run() { + task.run(handle); + } + } + + /// Updates the notification of a running foreground service. No-op by default. + public void updateForegroundServiceNotification(Object nativeHandle, String title, String body) { + } + + /// Stops a running foreground service. No-op by default. + public void stopForegroundService(Object nativeHandle) { + } + + /// Returns true if the platform supports deferrable background processing tasks. + public boolean isBackgroundProcessingSupported() { + return false; + } + + /// Schedules a deferrable background processing task. No-op when unsupported. + public void scheduleBackgroundProcessing(String id, long earliestBeginEpochMs, boolean requiresNetwork, boolean requiresPower, Runnable task) { + } + + /// Cancels a scheduled background processing task. No-op when unsupported. + public void cancelBackgroundProcessing(String id) { + } + + /// Subscribes the device to a push topic. No-op when unsupported. + public void subscribeToPushTopic(String topic) { + } + + /// Unsubscribes the device from a push topic. No-op when unsupported. + public void unsubscribeFromPushTopic(String topic) { + } + /// Gets the preferred time (in seconds) between background fetches. /// /// #### Returns diff --git a/CodenameOne/src/com/codename1/notifications/LocalNotification.java b/CodenameOne/src/com/codename1/notifications/LocalNotification.java index 3621694637..44da00b169 100644 --- a/CodenameOne/src/com/codename1/notifications/LocalNotification.java +++ b/CodenameOne/src/com/codename1/notifications/LocalNotification.java @@ -22,6 +22,9 @@ */ package com.codename1.notifications; +import java.util.ArrayList; +import java.util.List; + /// Local notifications are user notifications that are scheduled by the app itself. They /// are very similar to push notifications, except that they originate locally, rather than /// remotely. @@ -116,6 +119,19 @@ public class LocalNotification { private String alertImage = ""; private boolean foreground; + private String channelId; + private String groupId; + private boolean groupSummary; + private boolean fullScreenIntent; + private boolean timeSensitive; + private boolean ongoing; + private int progressMax; + private int progress; + private boolean progressIndeterminate; + private String customViewLayout; + private final List actions = new ArrayList(); + private MessagingStyle messagingStyle; + /// Gets the badge number to set for this notification. /// /// #### Returns @@ -269,4 +285,590 @@ public void setForeground(boolean foreground) { this.foreground = foreground; } + /// Gets the notification channel id this notification is posted to. Channels are an + /// Android concept; see `NotificationChannelBuilder`. On platforms without channels + /// this value is ignored. + /// + /// #### Returns + /// + /// the channel id, or null + public String getChannelId() { + return channelId; + } + + /// Sets the notification channel id this notification is posted to. + /// + /// #### Parameters + /// + /// - `channelId`: the channel id + /// + /// #### Returns + /// + /// this notification for chaining + public LocalNotification setChannelId(String channelId) { + this.channelId = channelId; + return this; + } + + /// Convenience alias for `#setAlertSound(String)` that returns this notification for + /// chaining. + /// + /// #### Parameters + /// + /// - `sound`: the alert sound file path + /// + /// #### Returns + /// + /// this notification for chaining + public LocalNotification setSound(String sound) { + setAlertSound(sound); + return this; + } + + /// Gets the group id used to bundle related notifications together in the shade. + /// + /// #### Returns + /// + /// the group id, or null + public String getGroupId() { + return groupId; + } + + /// Assigns this notification to a group. Notifications sharing a group id are + /// visually bundled. On iOS the group id maps to the notification thread identifier. + /// + /// #### Parameters + /// + /// - `groupId`: the group id + /// + /// #### Returns + /// + /// this notification for chaining + public LocalNotification setGroup(String groupId) { + this.groupId = groupId; + return this; + } + + /// Returns true if this notification is the summary for its group. + /// + /// #### Returns + /// + /// true if this is a group summary + public boolean isGroupSummary() { + return groupSummary; + } + + /// Marks this notification as the summary of its group (Android). The summary is the + /// single entry shown when the group is collapsed. + /// + /// #### Parameters + /// + /// - `groupSummary`: true to make this notification the group summary + /// + /// #### Returns + /// + /// this notification for chaining + public LocalNotification setGroupSummary(boolean groupSummary) { + this.groupSummary = groupSummary; + return this; + } + + /// Returns true if this notification should launch a full screen intent. + /// + /// #### Returns + /// + /// true if a full screen intent is requested + public boolean isFullScreenIntent() { + return fullScreenIntent; + } + + /// Requests that this notification launch a full screen intent (Android), used for + /// high priority interruptions such as incoming calls or alarms. Ignored on platforms + /// that do not support it. + /// + /// #### Parameters + /// + /// - `fullScreenIntent`: true to request a full screen intent + /// + /// #### Returns + /// + /// this notification for chaining + public LocalNotification setFullScreenIntent(boolean fullScreenIntent) { + this.fullScreenIntent = fullScreenIntent; + return this; + } + + /// Returns true if this notification is marked time sensitive. + /// + /// #### Returns + /// + /// true if time sensitive + public boolean isTimeSensitive() { + return timeSensitive; + } + + /// Marks this notification as time sensitive so it can break through Focus modes + /// (iOS) or be treated with elevated importance (Android). Requires the corresponding + /// permission to have been requested. + /// + /// #### Parameters + /// + /// - `timeSensitive`: true to mark the notification time sensitive + /// + /// #### Returns + /// + /// this notification for chaining + public LocalNotification setTimeSensitive(boolean timeSensitive) { + this.timeSensitive = timeSensitive; + return this; + } + + /// Returns true if this notification is ongoing. + /// + /// #### Returns + /// + /// true if ongoing + public boolean isOngoing() { + return ongoing; + } + + /// Marks this notification as ongoing (Android), meaning it cannot be dismissed by + /// the user and represents background activity in progress. Ignored on platforms that + /// do not support it. + /// + /// #### Parameters + /// + /// - `ongoing`: true to make the notification ongoing + /// + /// #### Returns + /// + /// this notification for chaining + public LocalNotification setOngoing(boolean ongoing) { + this.ongoing = ongoing; + return this; + } + + /// Returns the maximum value of the progress bar, or 0 if no progress bar is shown. + /// + /// #### Returns + /// + /// the progress maximum + public int getProgressMax() { + return progressMax; + } + + /// Returns the current progress value. + /// + /// #### Returns + /// + /// the current progress + public int getProgress() { + return progress; + } + + /// Shows a determinate progress bar on this notification (Android). + /// + /// #### Parameters + /// + /// - `max`: the maximum progress value + /// + /// - `current`: the current progress value + /// + /// #### Returns + /// + /// this notification for chaining + public LocalNotification setProgress(int max, int current) { + this.progressMax = max; + this.progress = current; + this.progressIndeterminate = false; + return this; + } + + /// Returns true if the progress bar is indeterminate. + /// + /// #### Returns + /// + /// true if the progress bar is indeterminate + public boolean isProgressIndeterminate() { + return progressIndeterminate; + } + + /// Shows an indeterminate (spinning) progress bar on this notification (Android). + /// + /// #### Parameters + /// + /// - `indeterminate`: true to show an indeterminate progress bar + /// + /// #### Returns + /// + /// this notification for chaining + public LocalNotification setIndeterminateProgress(boolean indeterminate) { + this.progressIndeterminate = indeterminate; + return this; + } + + /// Gets the custom view layout name used to render this notification. + /// + /// #### Returns + /// + /// the custom view layout name, or null + public String getCustomView() { + return customViewLayout; + } + + /// Sets a custom view layout name for this notification. On Android this maps to a + /// RemoteViews layout bundled in the native resources. On iOS a custom view is + /// rendered by a notification content extension keyed by the notification category. + /// Ignored on platforms that do not support custom notification views. + /// + /// #### Parameters + /// + /// - `customViewLayout`: the layout name + /// + /// #### Returns + /// + /// this notification for chaining + public LocalNotification setCustomView(String customViewLayout) { + this.customViewLayout = customViewLayout; + return this; + } + + /// Adds an action button to this notification. + /// + /// #### Parameters + /// + /// - `action`: the action to add + /// + /// #### Returns + /// + /// this notification for chaining + public LocalNotification addAction(Action action) { + actions.add(action); + return this; + } + + /// Adds a simple action button to this notification. + /// + /// #### Parameters + /// + /// - `id`: the action id reported back when the user taps the action + /// + /// - `title`: the button label + /// + /// #### Returns + /// + /// this notification for chaining + public LocalNotification addAction(String id, String title) { + return addAction(new Action(id, title)); + } + + /// Adds a quick reply action with an inline text input field. When the user submits + /// a reply the entered text is reported back via `com.codename1.push.PushContent#getTextResponse()` + /// alongside the action id. + /// + /// #### Parameters + /// + /// - `id`: the action id reported back when the user submits the reply + /// + /// - `title`: the button label + /// + /// - `placeholder`: placeholder text shown in the text input field + /// + /// - `replyButtonText`: the label for the send button + /// + /// #### Returns + /// + /// this notification for chaining + public LocalNotification addInputAction(String id, String title, String placeholder, String replyButtonText) { + Action a = new Action(id, title); + a.textInputPlaceholder = placeholder; + a.textInputButtonText = replyButtonText; + return addAction(a); + } + + /// Returns the list of action buttons configured on this notification. + /// + /// #### Returns + /// + /// the actions, never null + public List getActions() { + return actions; + } + + /// Configures this notification to render as a conversation (messaging style) + /// notification. Returns the `MessagingStyle` so messages can be added fluently. + /// + /// #### Parameters + /// + /// - `selfDisplayName`: the name representing the device user in the conversation + /// + /// #### Returns + /// + /// the messaging style for further configuration + public MessagingStyle asMessagingStyle(String selfDisplayName) { + this.messagingStyle = new MessagingStyle(selfDisplayName); + return this.messagingStyle; + } + + /// Returns the messaging style configured on this notification, or null if this is + /// not a messaging style notification. + /// + /// #### Returns + /// + /// the messaging style, or null + public MessagingStyle getMessagingStyle() { + return messagingStyle; + } + + /// A single action button attached to a local notification. An action may optionally + /// include an inline text input (quick reply) by setting a placeholder and reply + /// button text via `LocalNotification#addInputAction(String, String, String, String)`. + public static class Action { + private final String id; + private final String title; + private String icon; + private String textInputPlaceholder; + private String textInputButtonText; + + /// Creates an action. + /// + /// #### Parameters + /// + /// - `id`: the action id reported back when the user taps the action + /// + /// - `title`: the button label + public Action(String id, String title) { + this.id = id; + this.title = title; + } + + /// Creates an action with an icon. + /// + /// The icon is a platform resource NAME, not a numeric id (Codename One has no + /// numeric resource ids). On Android it is the name of a drawable bundled in the + /// `native/android` folder (the build copies it into `res/drawable`), resolved at + /// runtime by name; the file extension is optional and ignored. iOS notification + /// action buttons do not display icons, so the value is ignored there. + /// + /// #### Parameters + /// + /// - `id`: the action id reported back when the user taps the action + /// + /// - `title`: the button label + /// + /// - `icon`: the drawable resource name for the action (Android only) + public Action(String id, String title, String icon) { + this.id = id; + this.title = title; + this.icon = icon; + } + + /// Returns the action id. + /// + /// #### Returns + /// + /// the action id + public String getId() { + return id; + } + + /// Returns the button label. + /// + /// #### Returns + /// + /// the title + public String getTitle() { + return title; + } + + /// Returns the drawable resource name used for the action icon on Android, or null. + /// See `Action#Action(String, String, String)` for how it is resolved. + /// + /// #### Returns + /// + /// the drawable resource name, or null + public String getIcon() { + return icon; + } + + /// Returns the placeholder text for the inline text input, or null when the + /// action has no text input. + /// + /// #### Returns + /// + /// the text input placeholder, or null + public String getTextInputPlaceholder() { + return textInputPlaceholder; + } + + /// Returns the label of the reply button for the inline text input, or null when + /// the action has no text input. + /// + /// #### Returns + /// + /// the reply button text, or null + public String getTextInputButtonText() { + return textInputButtonText; + } + + /// Returns true if this action has an inline text input (quick reply). + /// + /// #### Returns + /// + /// true if this is a text input action + public boolean isTextInput() { + return textInputPlaceholder != null || textInputButtonText != null; + } + } + + /// Describes a conversation (messaging style) notification. A messaging style + /// notification renders a sequence of chat messages, each attributed to a sender, + /// and is the recommended presentation for chat and messaging apps. + public static class MessagingStyle { + private final String selfDisplayName; + private String conversationTitle; + private boolean groupConversation; + private final List messages = new ArrayList(); + + /// Creates a messaging style. + /// + /// #### Parameters + /// + /// - `selfDisplayName`: the name representing the device user + public MessagingStyle(String selfDisplayName) { + this.selfDisplayName = selfDisplayName; + } + + /// Sets the conversation title shown above the messages. + /// + /// #### Parameters + /// + /// - `t`: the conversation title + /// + /// #### Returns + /// + /// this messaging style for chaining + public MessagingStyle conversationTitle(String t) { + this.conversationTitle = t; + return this; + } + + /// Marks the conversation as a group conversation (more than two participants). + /// + /// #### Parameters + /// + /// - `b`: true if this is a group conversation + /// + /// #### Returns + /// + /// this messaging style for chaining + public MessagingStyle groupConversation(boolean b) { + this.groupConversation = b; + return this; + } + + /// Adds a message to the conversation. + /// + /// #### Parameters + /// + /// - `text`: the message text + /// + /// - `timestamp`: the message timestamp in milliseconds since the epoch + /// + /// - `senderName`: the display name of the sender, or null for the device user + /// + /// #### Returns + /// + /// this messaging style for chaining + public MessagingStyle addMessage(String text, long timestamp, String senderName) { + messages.add(new Message(text, timestamp, senderName)); + return this; + } + + /// Returns the name representing the device user. + /// + /// #### Returns + /// + /// the self display name + public String getSelfDisplayName() { + return selfDisplayName; + } + + /// Returns the conversation title. + /// + /// #### Returns + /// + /// the conversation title, or null + public String getConversationTitle() { + return conversationTitle; + } + + /// Returns true if this is a group conversation. + /// + /// #### Returns + /// + /// true if a group conversation + public boolean isGroupConversation() { + return groupConversation; + } + + /// Returns the messages in the conversation. + /// + /// #### Returns + /// + /// the messages, never null + public List getMessages() { + return messages; + } + + /// A single message within a messaging style notification. + public static class Message { + private final String text; + private final long timestamp; + private final String senderName; + + /// Creates a message. + /// + /// #### Parameters + /// + /// - `text`: the message text + /// + /// - `timestamp`: the timestamp in milliseconds since the epoch + /// + /// - `senderName`: the sender display name, or null for the device user + public Message(String text, long timestamp, String senderName) { + this.text = text; + this.timestamp = timestamp; + this.senderName = senderName; + } + + /// Returns the message text. + /// + /// #### Returns + /// + /// the text + public String getText() { + return text; + } + + /// Returns the message timestamp. + /// + /// #### Returns + /// + /// the timestamp in milliseconds since the epoch + public long getTimestamp() { + return timestamp; + } + + /// Returns the sender display name. + /// + /// #### Returns + /// + /// the sender name, or null for the device user + public String getSenderName() { + return senderName; + } + } + } + } diff --git a/CodenameOne/src/com/codename1/notifications/NotificationChannelBuilder.java b/CodenameOne/src/com/codename1/notifications/NotificationChannelBuilder.java new file mode 100644 index 0000000000..07f02fb580 --- /dev/null +++ b/CodenameOne/src/com/codename1/notifications/NotificationChannelBuilder.java @@ -0,0 +1,386 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.notifications; + +import com.codename1.ui.Display; + +/// Builds and registers a notification channel. Notification channels are an Android +/// concept (introduced in Android O) that let the user control the behavior of groups +/// of notifications: importance, sound, vibration, lights, lockscreen visibility and +/// whether a badge is shown. Once a channel is created its user-controllable settings +/// cannot be changed programmatically, so build it once at app startup. +/// +/// On platforms without a channel concept (iOS, desktop) registering a channel is a +/// no-op, but the channel id you assign to a `LocalNotification` is still carried so the +/// notification behaves consistently. +/// +/// Usage +/// ```java +/// new NotificationChannelBuilder("messages", "Messages") +/// .description("Incoming chat messages") +/// .importance(NotificationChannelBuilder.IMPORTANCE_HIGH) +/// .sound("/notification_sound_ping.mp3") +/// .enableVibration(true) +/// .register(); +/// ``` +/// +/// #### See also +/// +/// - LocalNotification#setChannelId(String) +public class NotificationChannelBuilder { + + /// Channel importance: a no-importance channel does not appear in the shade. + public static final int IMPORTANCE_NONE = 0; + + /// Channel importance: shows nowhere, is not intrusive. + public static final int IMPORTANCE_MIN = 1; + + /// Channel importance: shows in the shade and status bar but is not intrusive. + public static final int IMPORTANCE_LOW = 2; + + /// Channel importance: shows everywhere, makes noise but does not visually intrude. + public static final int IMPORTANCE_DEFAULT = 3; + + /// Channel importance: makes noise and shows as a heads-up notification. + public static final int IMPORTANCE_HIGH = 4; + + /// Channel importance: the highest level (rarely needed). + public static final int IMPORTANCE_MAX = 5; + + /// Lockscreen visibility: do not reveal any part of the notification on a secure + /// lockscreen. + public static final int VISIBILITY_SECRET = -1; + + /// Lockscreen visibility: show the notification but hide sensitive content on a + /// secure lockscreen. + public static final int VISIBILITY_PRIVATE = 0; + + /// Lockscreen visibility: show the notification in its entirety on the lockscreen. + public static final int VISIBILITY_PUBLIC = 1; + + private final String id; + private final String name; + private String description; + private int importance = IMPORTANCE_DEFAULT; + private String sound; + private boolean vibrationEnabled; + private long[] vibrationPattern; + private boolean lightsEnabled; + private int lightColor; + private int lockscreenVisibility = VISIBILITY_PRIVATE; + private String group; + private boolean showBadge = true; + + /// Creates a channel builder. + /// + /// #### Parameters + /// + /// - `id`: a stable channel id used when posting notifications + /// + /// - `name`: the user-visible channel name shown in the system settings + public NotificationChannelBuilder(String id, String name) { + this.id = id; + this.name = name; + } + + /// Sets the user-visible channel description. + /// + /// #### Parameters + /// + /// - `d`: the description + /// + /// #### Returns + /// + /// this builder for chaining + public NotificationChannelBuilder description(String d) { + this.description = d; + return this; + } + + /// Sets the channel importance, one of the `IMPORTANCE_` constants. + /// + /// #### Parameters + /// + /// - `imp`: the importance level + /// + /// #### Returns + /// + /// this builder for chaining + public NotificationChannelBuilder importance(int imp) { + this.importance = imp; + return this; + } + + /// Sets the sound played for notifications on this channel. The file name must start + /// with the "notification_sound" prefix and be bundled with the app. + /// + /// #### Parameters + /// + /// - `soundFile`: the sound file path + /// + /// #### Returns + /// + /// this builder for chaining + public NotificationChannelBuilder sound(String soundFile) { + this.sound = soundFile; + return this; + } + + /// Enables or disables vibration for this channel. + /// + /// #### Parameters + /// + /// - `b`: true to enable vibration + /// + /// #### Returns + /// + /// this builder for chaining + public NotificationChannelBuilder enableVibration(boolean b) { + this.vibrationEnabled = b; + return this; + } + + /// Sets the vibration pattern (alternating off/on durations in milliseconds) and + /// enables vibration. + /// + /// #### Parameters + /// + /// - `pattern`: the vibration pattern + /// + /// #### Returns + /// + /// this builder for chaining + public NotificationChannelBuilder vibrationPattern(long[] pattern) { + this.vibrationPattern = pattern; + this.vibrationEnabled = true; + return this; + } + + /// Enables or disables the notification light for this channel. + /// + /// #### Parameters + /// + /// - `b`: true to enable lights + /// + /// #### Returns + /// + /// this builder for chaining + public NotificationChannelBuilder enableLights(boolean b) { + this.lightsEnabled = b; + return this; + } + + /// Sets the notification light color (as an RGB integer) and enables lights. + /// + /// #### Parameters + /// + /// - `rgb`: the light color + /// + /// #### Returns + /// + /// this builder for chaining + public NotificationChannelBuilder lightColor(int rgb) { + this.lightColor = rgb; + this.lightsEnabled = true; + return this; + } + + /// Sets the lockscreen visibility, one of the `VISIBILITY_` constants. + /// + /// #### Parameters + /// + /// - `v`: the lockscreen visibility + /// + /// #### Returns + /// + /// this builder for chaining + public NotificationChannelBuilder lockscreenVisibility(int v) { + this.lockscreenVisibility = v; + return this; + } + + /// Assigns this channel to a channel group. The group must be created with + /// `#createChannelGroup(String, String)` before or after the channel is registered. + /// + /// #### Parameters + /// + /// - `groupId`: the channel group id + /// + /// #### Returns + /// + /// this builder for chaining + public NotificationChannelBuilder group(String groupId) { + this.group = groupId; + return this; + } + + /// Controls whether notifications on this channel may show a launcher badge. + /// + /// #### Parameters + /// + /// - `b`: true to allow a badge + /// + /// #### Returns + /// + /// this builder for chaining + public NotificationChannelBuilder showBadge(boolean b) { + this.showBadge = b; + return this; + } + + /// Returns the channel id. + /// + /// #### Returns + /// + /// the channel id + public String getId() { + return id; + } + + /// Returns the user-visible channel name. + /// + /// #### Returns + /// + /// the channel name + public String getName() { + return name; + } + + /// Returns the channel description. + /// + /// #### Returns + /// + /// the description, or null + public String getDescription() { + return description; + } + + /// Returns the channel importance. + /// + /// #### Returns + /// + /// the importance level + public int getImportance() { + return importance; + } + + /// Returns the channel sound file path. + /// + /// #### Returns + /// + /// the sound file, or null + public String getSound() { + return sound; + } + + /// Returns true if vibration is enabled. + /// + /// #### Returns + /// + /// true if vibration is enabled + public boolean isVibrationEnabled() { + return vibrationEnabled; + } + + /// Returns the vibration pattern. + /// + /// #### Returns + /// + /// the vibration pattern, or null + public long[] getVibrationPattern() { + return vibrationPattern; + } + + /// Returns true if lights are enabled. + /// + /// #### Returns + /// + /// true if lights are enabled + public boolean isLightsEnabled() { + return lightsEnabled; + } + + /// Returns the light color. + /// + /// #### Returns + /// + /// the light color as an RGB integer + public int getLightColor() { + return lightColor; + } + + /// Returns the lockscreen visibility. + /// + /// #### Returns + /// + /// the lockscreen visibility + public int getLockscreenVisibility() { + return lockscreenVisibility; + } + + /// Returns the channel group id. + /// + /// #### Returns + /// + /// the group id, or null + public String getGroup() { + return group; + } + + /// Returns true if a launcher badge is allowed. + /// + /// #### Returns + /// + /// true if a badge is allowed + public boolean isShowBadge() { + return showBadge; + } + + /// Registers this channel with the platform. On platforms without channels this is a + /// no-op. + public void register() { + Display.getInstance().registerNotificationChannel(this); + } + + /// Deletes a previously registered channel. On platforms without channels this is a + /// no-op. + /// + /// #### Parameters + /// + /// - `id`: the channel id to delete + public static void deleteChannel(String id) { + Display.getInstance().deleteNotificationChannel(id); + } + + /// Creates a channel group, which visually groups channels in the system settings. + /// On platforms without channels this is a no-op. + /// + /// #### Parameters + /// + /// - `groupId`: a stable group id + /// + /// - `groupName`: the user-visible group name + public static void createChannelGroup(String groupId, String groupName) { + Display.getInstance().createNotificationChannelGroup(groupId, groupName); + } +} diff --git a/CodenameOne/src/com/codename1/notifications/NotificationPermissionCallback.java b/CodenameOne/src/com/codename1/notifications/NotificationPermissionCallback.java new file mode 100644 index 0000000000..500ce76766 --- /dev/null +++ b/CodenameOne/src/com/codename1/notifications/NotificationPermissionCallback.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.notifications; + +/// Callback used to receive the outcome of a notification permission request issued +/// via `com.codename1.ui.Display#requestNotificationPermission(NotificationPermissionCallback)`. +/// +/// The callback is delivered on the EDT. +/// +/// #### See also +/// +/// - NotificationPermissionResult +/// +/// - NotificationPermissionRequest +public interface NotificationPermissionCallback { + + /// Invoked once the platform resolves the permission request. + /// + /// #### Parameters + /// + /// - `result`: the result describing whether permission was granted and at which level + void notificationPermissionResult(NotificationPermissionResult result); +} diff --git a/CodenameOne/src/com/codename1/notifications/NotificationPermissionRequest.java b/CodenameOne/src/com/codename1/notifications/NotificationPermissionRequest.java new file mode 100644 index 0000000000..c321edf3a8 --- /dev/null +++ b/CodenameOne/src/com/codename1/notifications/NotificationPermissionRequest.java @@ -0,0 +1,320 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.notifications; + +/// Describes which notification capabilities an app wants to request when calling +/// `com.codename1.ui.Display#requestNotificationPermission(NotificationPermissionRequest, NotificationPermissionCallback)`. +/// +/// By default `alert`, `sound` and `badge` are requested. Additional options such as +/// `provisional`, `critical`, `timeSensitive`, `carPlay` and `announcement` map to iOS +/// `UNAuthorizationOptions`. On Android only the existence of any requested capability +/// matters; the granular options that have no Android equivalent are ignored. +/// +/// Usage +/// ```java +/// NotificationPermissionRequest req = new NotificationPermissionRequest() +/// .provisional(true) +/// .timeSensitive(true); +/// Display.getInstance().requestNotificationPermission(req, result -> { +/// if (result.isGranted()) { +/// // schedule notifications +/// } +/// }); +/// ``` +/// +/// #### See also +/// +/// - NotificationPermissionCallback +/// +/// - NotificationPermissionResult +public class NotificationPermissionRequest { + + // Bit values match iOS UNAuthorizationOption constants. + private static final int OPTION_BADGE = 1; + private static final int OPTION_SOUND = 2; + private static final int OPTION_ALERT = 4; + private static final int OPTION_CAR_PLAY = 8; + private static final int OPTION_CRITICAL_ALERT = 16; + private static final int OPTION_PROVIDES_SETTINGS = 32; + private static final int OPTION_PROVISIONAL = 64; + private static final int OPTION_ANNOUNCEMENT = 128; + + private boolean alert = true; + private boolean sound = true; + private boolean badge = true; + private boolean provisional; + private boolean critical; + private boolean timeSensitive; + private boolean carPlay; + private boolean announcement; + private boolean providesAppSettings; + + /// Requests permission to display alerts. Enabled by default. + /// + /// #### Parameters + /// + /// - `b`: true to request alert permission + /// + /// #### Returns + /// + /// this request for chaining + public NotificationPermissionRequest alert(boolean b) { + this.alert = b; + return this; + } + + /// Requests permission to play notification sounds. Enabled by default. + /// + /// #### Parameters + /// + /// - `b`: true to request sound permission + /// + /// #### Returns + /// + /// this request for chaining + public NotificationPermissionRequest sound(boolean b) { + this.sound = b; + return this; + } + + /// Requests permission to update the app icon badge. Enabled by default. + /// + /// #### Parameters + /// + /// - `b`: true to request badge permission + /// + /// #### Returns + /// + /// this request for chaining + public NotificationPermissionRequest badge(boolean b) { + this.badge = b; + return this; + } + + /// Requests provisional authorization (iOS). Provisional notifications are delivered + /// quietly to the notification center without an explicit prompt. Ignored on platforms + /// that do not support it. + /// + /// #### Parameters + /// + /// - `b`: true to request provisional authorization + /// + /// #### Returns + /// + /// this request for chaining + public NotificationPermissionRequest provisional(boolean b) { + this.provisional = b; + return this; + } + + /// Requests permission to send critical alerts (iOS). Critical alerts bypass Do Not + /// Disturb and the mute switch and require a special Apple entitlement. Without the + /// entitlement the request is silently downgraded. Ignored on platforms that do not + /// support it. + /// + /// #### Parameters + /// + /// - `b`: true to request critical alert permission + /// + /// #### Returns + /// + /// this request for chaining + public NotificationPermissionRequest critical(boolean b) { + this.critical = b; + return this; + } + + /// Requests the ability to mark notifications as time sensitive (iOS). Ignored on + /// platforms that do not support it. + /// + /// #### Parameters + /// + /// - `b`: true to request the time sensitive interruption level + /// + /// #### Returns + /// + /// this request for chaining + public NotificationPermissionRequest timeSensitive(boolean b) { + this.timeSensitive = b; + return this; + } + + /// Requests permission to display notifications in CarPlay (iOS). Ignored elsewhere. + /// + /// #### Parameters + /// + /// - `b`: true to request CarPlay permission + /// + /// #### Returns + /// + /// this request for chaining + public NotificationPermissionRequest carPlay(boolean b) { + this.carPlay = b; + return this; + } + + /// Requests permission for Siri to read out notifications (iOS). Ignored elsewhere. + /// + /// #### Parameters + /// + /// - `b`: true to request announcement permission + /// + /// #### Returns + /// + /// this request for chaining + public NotificationPermissionRequest announcement(boolean b) { + this.announcement = b; + return this; + } + + /// Requests that the app provide its own in-app notification settings (iOS). Ignored + /// elsewhere. + /// + /// #### Parameters + /// + /// - `b`: true to advertise in-app notification settings + /// + /// #### Returns + /// + /// this request for chaining + public NotificationPermissionRequest providesAppSettings(boolean b) { + this.providesAppSettings = b; + return this; + } + + /// Returns true if alert permission is requested. + /// + /// #### Returns + /// + /// true if alerts are requested + public boolean isAlert() { + return alert; + } + + /// Returns true if sound permission is requested. + /// + /// #### Returns + /// + /// true if sound is requested + public boolean isSound() { + return sound; + } + + /// Returns true if badge permission is requested. + /// + /// #### Returns + /// + /// true if badge is requested + public boolean isBadge() { + return badge; + } + + /// Returns true if provisional authorization is requested. + /// + /// #### Returns + /// + /// true if provisional authorization is requested + public boolean isProvisional() { + return provisional; + } + + /// Returns true if critical alert permission is requested. + /// + /// #### Returns + /// + /// true if critical alerts are requested + public boolean isCritical() { + return critical; + } + + /// Returns true if the time sensitive interruption level is requested. + /// + /// #### Returns + /// + /// true if time sensitive notifications are requested + public boolean isTimeSensitive() { + return timeSensitive; + } + + /// Returns true if CarPlay permission is requested. + /// + /// #### Returns + /// + /// true if CarPlay is requested + public boolean isCarPlay() { + return carPlay; + } + + /// Returns true if announcement permission is requested. + /// + /// #### Returns + /// + /// true if announcement is requested + public boolean isAnnouncement() { + return announcement; + } + + /// Returns true if the app advertises in-app notification settings. + /// + /// #### Returns + /// + /// true if the app provides its own settings + public boolean isProvidesAppSettings() { + return providesAppSettings; + } + + /// Converts this request into the bitmask consumed by the iOS native layer. The bit + /// values match the iOS `UNAuthorizationOption` constants. + /// + /// #### Returns + /// + /// the authorization options bitmask + public int toAuthorizationOptionsMask() { + int mask = 0; + if (alert) { + mask |= OPTION_ALERT; + } + if (sound) { + mask |= OPTION_SOUND; + } + if (badge) { + mask |= OPTION_BADGE; + } + if (provisional) { + mask |= OPTION_PROVISIONAL; + } + if (critical) { + mask |= OPTION_CRITICAL_ALERT; + } + if (carPlay) { + mask |= OPTION_CAR_PLAY; + } + if (announcement) { + mask |= OPTION_ANNOUNCEMENT; + } + if (providesAppSettings) { + mask |= OPTION_PROVIDES_SETTINGS; + } + return mask; + } +} diff --git a/CodenameOne/src/com/codename1/notifications/NotificationPermissionResult.java b/CodenameOne/src/com/codename1/notifications/NotificationPermissionResult.java new file mode 100644 index 0000000000..dbcc08502e --- /dev/null +++ b/CodenameOne/src/com/codename1/notifications/NotificationPermissionResult.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.notifications; + +/// The result of a notification permission request delivered to a +/// `NotificationPermissionCallback`. It exposes the granular authorization level the +/// platform returned. +/// +/// The levels mirror the iOS `UNAuthorizationStatus` values. On platforms with only a +/// binary granted/denied model (such as Android) the level is either `AuthorizationLevel#AUTHORIZED` +/// or `AuthorizationLevel#DENIED`. +/// +/// #### See also +/// +/// - NotificationPermissionCallback +/// +/// - NotificationPermissionRequest +public class NotificationPermissionResult { + + /// The granular authorization level returned by the platform. The constants are + /// ordered from least to most permissive, so any level after `#DENIED` means + /// notifications are permitted. + public enum AuthorizationLevel { + /// The user has not yet made a choice regarding notification permission. + NOT_DETERMINED, + + /// The user explicitly denied notification permission. + DENIED, + + /// The user granted full notification permission. + AUTHORIZED, + + /// Notifications were authorized provisionally (delivered quietly without an + /// explicit prompt). Applies to iOS provisional authorization. + PROVISIONAL, + + /// Notifications were authorized for a limited amount of time (App Clip style). + EPHEMERAL + } + + private final AuthorizationLevel authorizationLevel; + + /// Creates a new result. + /// + /// #### Parameters + /// + /// - `authorizationLevel`: the granular authorization level + public NotificationPermissionResult(AuthorizationLevel authorizationLevel) { + this.authorizationLevel = authorizationLevel; + } + + /// Returns true if notifications are permitted. This is true for any level more + /// permissive than `AuthorizationLevel#DENIED` (authorized, provisional or ephemeral). + /// + /// #### Returns + /// + /// true if notifications can be posted + public boolean isGranted() { + return authorizationLevel.compareTo(AuthorizationLevel.DENIED) > 0; + } + + /// Returns the granular authorization level. + /// + /// #### Returns + /// + /// the authorization level + public AuthorizationLevel getAuthorizationLevel() { + return authorizationLevel; + } + + /// Returns true if the authorization is provisional (quiet) rather than explicit. + /// + /// #### Returns + /// + /// true if the authorization level is `AuthorizationLevel#PROVISIONAL` + public boolean isProvisional() { + return authorizationLevel == AuthorizationLevel.PROVISIONAL; + } +} diff --git a/CodenameOne/src/com/codename1/push/Push.java b/CodenameOne/src/com/codename1/push/Push.java index b6d6e677df..db2260f8be 100644 --- a/CodenameOne/src/com/codename1/push/Push.java +++ b/CodenameOne/src/com/codename1/push/Push.java @@ -251,6 +251,33 @@ public static String getPushKey() { return null; } + /// Subscribes this device to a push topic. Topics are a fan-out mechanism: a single + /// server-side push to a topic is delivered to every device subscribed to it, without + /// the server needing to track individual device keys. + /// + /// On Android this maps to Firebase Cloud Messaging topics. On iOS, where the stock + /// push transport is raw APNs which has no native topic concept, this is a no-op and + /// topic fan-out must be performed server side; a warning is logged. + /// + /// #### Parameters + /// + /// - `topic`: the topic name + public static void subscribeToTopic(String topic) { + Display.getInstance().subscribeToPushTopic(topic); + } + + /// Unsubscribes this device from a previously subscribed push topic. + /// + /// On Android this maps to Firebase Cloud Messaging topics. On iOS this is a no-op; + /// a warning is logged. + /// + /// #### Parameters + /// + /// - `topic`: the topic name + public static void unsubscribeFromTopic(String topic) { + Display.getInstance().unsubscribeFromPushTopic(topic); + } + /// Sends a push message and returns true if server delivery succeeded, notice that the /// push message isn't guaranteed to reach all devices. /// This method uses the new push servers diff --git a/CodenameOne/src/com/codename1/push/PushContent.java b/CodenameOne/src/com/codename1/push/PushContent.java index 31c1b8385a..3a58c1aaba 100644 --- a/CodenameOne/src/com/codename1/push/PushContent.java +++ b/CodenameOne/src/com/codename1/push/PushContent.java @@ -37,6 +37,7 @@ public final class PushContent { private final String category; private final String metaData; private final String actionId; + private final String actionTitle; private final String textResponse; private int type; @@ -47,6 +48,7 @@ private PushContent() { category = p("category", null); metaData = p("metaData", null); actionId = p("actionId", null); + actionTitle = p("actionTitle", null); textResponse = p("textResponse", null); String typeVal = p("type", null); if (typeVal != null) { @@ -59,7 +61,7 @@ private PushContent() { } private static String[] keys() { - return new String[]{"title", "body", "imageUrl", "category", "metaData", "actionId", "textResponse"}; + return new String[]{"title", "body", "imageUrl", "category", "metaData", "actionId", "actionTitle", "textResponse"}; } /// Checks if there is pending push content to retrieve. @@ -297,6 +299,31 @@ public static void setActionId(String actionId) { setProperty("actionId", actionId); } + /// If the user selected an action on the notification, then the title (button label) + /// of the selected action is available here. This applies both to push notification + /// actions and to local notification actions. If the user did not tap an action, this + /// is null. + /// + /// #### Returns + /// + /// The title of the action that was selected by the user, or null. + public String getActionTitle() { + return actionTitle; + } + + /// Sets the action title of the notification content. + /// + /// #### Parameters + /// + /// - `actionTitle`: the title of the action that was selected + /// + /// #### Deprecated + /// + /// For internal use only. + public static void setActionTitle(String actionTitle) { + setProperty("actionTitle", actionTitle); + } + /// If the push notification action included a text field for the user to enter a response, then that response /// will be returned here. For notifications that don't include a response, this will return null. /// diff --git a/CodenameOne/src/com/codename1/share/SharedContent.java b/CodenameOne/src/com/codename1/share/SharedContent.java new file mode 100644 index 0000000000..44b93e6be1 --- /dev/null +++ b/CodenameOne/src/com/codename1/share/SharedContent.java @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.share; + +import java.util.ArrayList; +import java.util.List; + +/// Content shared into the app from another application via the platform share sheet +/// (Android) or share extension / open-in flow (iOS). Delivered to the app through +/// `com.codename1.system.Lifecycle#onReceivedSharedContent(SharedContent)`. +/// +/// A shared payload may contain multiple items of mixed type (for example several images +/// shared at once). File and image items are exposed as paths in the Codename One +/// `com.codename1.io.FileSystemStorage`, having been copied out of the originating app's +/// sandbox by the platform port, so application code is fully platform neutral. +/// +/// Usage +/// ```java +/// public class MyApp extends Lifecycle { +/// public void onReceivedSharedContent(SharedContent content) { +/// if (content.hasText()) { +/// handleText(content.getFirstItem().getText()); +/// } +/// for (SharedContent.Item item : content.getItems()) { +/// if (item.getType() == SharedContent.TYPE_IMAGE) { +/// importImage(item.getFilePath()); +/// } +/// } +/// } +/// } +/// ``` +public final class SharedContent { + + /// Item type: plain text. + public static final int TYPE_TEXT = 1; + + /// Item type: a URL (delivered as text). + public static final int TYPE_URL = 2; + + /// Item type: a file, exposed as a `FileSystemStorage` path. + public static final int TYPE_FILE = 3; + + /// Item type: an image, exposed as a `FileSystemStorage` path. + public static final int TYPE_IMAGE = 4; + + private final String subject; + private final Item[] items; + + private SharedContent(String subject, Item[] items) { + this.subject = subject; + this.items = items; + } + + /// Returns the optional subject of the shared content (for example an email subject), + /// or null if none was supplied. + /// + /// #### Returns + /// + /// the subject, or null + public String getSubject() { + return subject; + } + + /// Returns the items contained in this shared payload. + /// + /// #### Returns + /// + /// the items, never null + public Item[] getItems() { + return items; + } + + /// Returns the first item, or null if the payload is empty. + /// + /// #### Returns + /// + /// the first item, or null + public Item getFirstItem() { + return items.length == 0 ? null : items[0]; + } + + /// Returns true if any item is text or a URL. + /// + /// #### Returns + /// + /// true if textual content is present + public boolean hasText() { + for (Item i : items) { + if (i.type == TYPE_TEXT || i.type == TYPE_URL) { + return true; + } + } + return false; + } + + /// Returns true if any item is a file or an image. + /// + /// #### Returns + /// + /// true if file content is present + public boolean hasFiles() { + for (Item i : items) { + if (i.type == TYPE_FILE || i.type == TYPE_IMAGE) { + return true; + } + } + return false; + } + + /// Creates a new builder. Intended for use by the platform ports that construct the + /// shared content before delivering it to the app. + /// + /// #### Returns + /// + /// a new builder + public static Builder builder() { + return new Builder(); + } + + /// A single item within a shared payload. + public static final class Item { + private final int type; + private final String mimeType; + private final String text; + private final String filePath; + private final String title; + + Item(int type, String mimeType, String text, String filePath, String title) { + this.type = type; + this.mimeType = mimeType; + this.text = text; + this.filePath = filePath; + this.title = title; + } + + /// Returns the item type, one of the `TYPE_` constants. + /// + /// #### Returns + /// + /// the item type + public int getType() { + return type; + } + + /// Returns the MIME type of the item, or null if unknown. + /// + /// #### Returns + /// + /// the MIME type, or null + public String getMimeType() { + return mimeType; + } + + /// Returns the textual value for text and URL items, or null for file items. + /// + /// #### Returns + /// + /// the text, or null + public String getText() { + return text; + } + + /// Returns the `FileSystemStorage` path for file and image items, or null for + /// textual items. + /// + /// #### Returns + /// + /// the file path, or null + public String getFilePath() { + return filePath; + } + + /// Returns an optional title for the item, or null. + /// + /// #### Returns + /// + /// the title, or null + public String getTitle() { + return title; + } + } + + /// Builder for `SharedContent`. Used by the platform ports. + public static final class Builder { + private String subject; + private final List items = new ArrayList(); + + /// Sets the subject. + /// + /// #### Parameters + /// + /// - `s`: the subject + /// + /// #### Returns + /// + /// this builder for chaining + public Builder subject(String s) { + this.subject = s; + return this; + } + + /// Adds a text item. + /// + /// #### Parameters + /// + /// - `text`: the text + /// + /// #### Returns + /// + /// this builder for chaining + public Builder addText(String text) { + items.add(new Item(TYPE_TEXT, "text/plain", text, null, null)); + return this; + } + + /// Adds a URL item. + /// + /// #### Parameters + /// + /// - `url`: the URL + /// + /// #### Returns + /// + /// this builder for chaining + public Builder addUrl(String url) { + items.add(new Item(TYPE_URL, "text/uri-list", url, null, null)); + return this; + } + + /// Adds a file item. + /// + /// #### Parameters + /// + /// - `mime`: the MIME type, or null + /// + /// - `cn1Path`: the `FileSystemStorage` path of the copied file + /// + /// - `title`: an optional title, or null + /// + /// #### Returns + /// + /// this builder for chaining + public Builder addFile(String mime, String cn1Path, String title) { + items.add(new Item(TYPE_FILE, mime, null, cn1Path, title)); + return this; + } + + /// Adds an image item. + /// + /// #### Parameters + /// + /// - `mime`: the MIME type, or null + /// + /// - `cn1Path`: the `FileSystemStorage` path of the copied image + /// + /// - `title`: an optional title, or null + /// + /// #### Returns + /// + /// this builder for chaining + public Builder addImage(String mime, String cn1Path, String title) { + items.add(new Item(TYPE_IMAGE, mime, null, cn1Path, title)); + return this; + } + + /// Builds the immutable shared content. + /// + /// #### Returns + /// + /// the shared content + public SharedContent build() { + return new SharedContent(subject, items.toArray(new Item[items.size()])); + } + } +} diff --git a/CodenameOne/src/com/codename1/system/Lifecycle.java b/CodenameOne/src/com/codename1/system/Lifecycle.java index 842f611adc..8b29c39b50 100644 --- a/CodenameOne/src/com/codename1/system/Lifecycle.java +++ b/CodenameOne/src/com/codename1/system/Lifecycle.java @@ -146,6 +146,19 @@ public void stop() { /// Callback when the app is destroyed public void destroy() { } + + /// Invoked when the app is launched or resumed because the user shared content + /// (text, a URL, a file or an image) into it from another application. The default + /// implementation does nothing; override it to handle the shared payload. + /// + /// This is delivered on the EDT. File and image items are exposed as + /// `com.codename1.io.FileSystemStorage` paths. + /// + /// #### Parameters + /// + /// - `content`: the shared content + public void onReceivedSharedContent(com.codename1.share.SharedContent content) { + } /// Returns the form currently stored by this `Lifecycle` instance for /// application resume handling. diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 1305ef2762..68295a0bd1 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -45,6 +45,11 @@ import com.codename1.media.MediaRecorderBuilder; import com.codename1.messaging.Message; import com.codename1.notifications.LocalNotification; +import com.codename1.notifications.NotificationChannelBuilder; +import com.codename1.notifications.NotificationPermissionCallback; +import com.codename1.notifications.NotificationPermissionRequest; +import com.codename1.background.ForegroundService; +import com.codename1.background.WorkRequest; import com.codename1.payment.Purchase; import com.codename1.plugin.PluginSupport; import com.codename1.plugin.event.IsGalleryTypeSupportedEvent; @@ -5827,6 +5832,206 @@ public void cancelLocalNotification(String notificationId) { impl.cancelLocalNotification(notificationId); } + /// Requests permission to post notifications using a default request (alert, sound and + /// badge). The result is delivered to the callback on the EDT. On platforms without a + /// notification permission model the callback reports the permission as granted. + /// + /// #### Parameters + /// + /// - `callback`: the callback to receive the result + public void requestNotificationPermission(NotificationPermissionCallback callback) { + requestNotificationPermission(new NotificationPermissionRequest(), callback); + } + + /// Requests permission to post notifications with the capabilities described by the + /// given request. The result is delivered to the callback on the EDT. + /// + /// #### Parameters + /// + /// - `request`: describes which notification capabilities to request + /// + /// - `callback`: the callback to receive the result + public void requestNotificationPermission(NotificationPermissionRequest request, NotificationPermissionCallback callback) { + impl.requestNotificationPermission(request, callback); + } + + /// Registers a notification channel (Android). No-op on platforms without channels. + /// + /// #### Parameters + /// + /// - `builder`: the channel definition + public void registerNotificationChannel(NotificationChannelBuilder builder) { + impl.registerNotificationChannel(builder); + } + + /// Deletes a notification channel (Android). No-op on platforms without channels. + /// + /// #### Parameters + /// + /// - `channelId`: the channel id to delete + public void deleteNotificationChannel(String channelId) { + impl.deleteNotificationChannel(channelId); + } + + /// Creates a notification channel group (Android). No-op on platforms without channels. + /// + /// #### Parameters + /// + /// - `groupId`: the group id + /// + /// - `groupName`: the user-visible group name + public void createNotificationChannelGroup(String groupId, String groupName) { + impl.createNotificationChannelGroup(groupId, groupName); + } + + /// Schedules constraint-aware background work. Used internally by + /// `com.codename1.background.BackgroundWork`. + /// + /// #### Parameters + /// + /// - `request`: the work request + public void scheduleBackgroundWork(WorkRequest request) { + impl.scheduleBackgroundWork(request); + } + + /// Cancels scheduled background work by id. + /// + /// #### Parameters + /// + /// - `workId`: the work id + public void cancelBackgroundWork(String workId) { + impl.cancelBackgroundWork(workId); + } + + /// Returns true if constraint-aware background work is supported. + /// + /// #### Returns + /// + /// true if supported + public boolean isBackgroundWorkSupported() { + return impl.isBackgroundWorkSupported(); + } + + /// Schedules a deferrable background processing task. Used internally by + /// `com.codename1.background.BackgroundTask`. + /// + /// #### Parameters + /// + /// - `id`: the task id + /// + /// - `earliestBeginEpochMs`: the earliest begin time in milliseconds since the epoch, or 0 + /// + /// - `requiresNetwork`: true if network is required + /// + /// - `requiresPower`: true if charging is required + /// + /// - `task`: the work to run + public void scheduleBackgroundProcessing(String id, long earliestBeginEpochMs, boolean requiresNetwork, boolean requiresPower, Runnable task) { + impl.scheduleBackgroundProcessing(id, earliestBeginEpochMs, requiresNetwork, requiresPower, task); + } + + /// Cancels a scheduled background processing task. + /// + /// #### Parameters + /// + /// - `id`: the task id + public void cancelBackgroundProcessing(String id) { + impl.cancelBackgroundProcessing(id); + } + + /// Returns true if deferrable background processing is supported. + /// + /// #### Returns + /// + /// true if supported + public boolean isBackgroundProcessingSupported() { + return impl.isBackgroundProcessingSupported(); + } + + /// Starts a foreground service. Used internally by + /// `com.codename1.background.ForegroundService`. + /// + /// #### Parameters + /// + /// - `channelId`: the notification channel id + /// + /// - `title`: the notification title + /// + /// - `body`: the notification body + /// + /// - `iconName`: the small icon resource name, or null + /// + /// - `task`: the task to run + /// + /// - `handle`: the service handle passed to the task + /// + /// #### Returns + /// + /// an opaque native handle + public Object startForegroundService(String channelId, String title, String body, String iconName, ForegroundService.Task task, ForegroundService handle) { + return impl.startForegroundService(channelId, title, body, iconName, task, handle); + } + + /// Updates a foreground service notification. + /// + /// #### Parameters + /// + /// - `nativeHandle`: the handle returned by `#startForegroundService` + /// + /// - `title`: the new title + /// + /// - `body`: the new body + public void updateForegroundServiceNotification(Object nativeHandle, String title, String body) { + impl.updateForegroundServiceNotification(nativeHandle, title, body); + } + + /// Stops a foreground service. + /// + /// #### Parameters + /// + /// - `nativeHandle`: the handle returned by `#startForegroundService` + public void stopForegroundService(Object nativeHandle) { + impl.stopForegroundService(nativeHandle); + } + + /// Returns true if foreground services are supported. + /// + /// #### Returns + /// + /// true if supported + public boolean isForegroundServiceSupported() { + return impl.isForegroundServiceSupported(); + } + + /// Returns true if the platform can receive shared content from other apps. + /// + /// #### Returns + /// + /// true if supported + public boolean isReceiveSharedContentSupported() { + return impl.isReceiveSharedContentSupported(); + } + + /// Subscribes the device to a push topic. Used internally by + /// `com.codename1.push.Push`. + /// + /// #### Parameters + /// + /// - `topic`: the topic name + public void subscribeToPushTopic(String topic) { + impl.subscribeToPushTopic(topic); + } + + /// Unsubscribes the device from a push topic. Used internally by + /// `com.codename1.push.Push`. + /// + /// #### Parameters + /// + /// - `topic`: the topic name + public void unsubscribeFromPushTopic(String topic) { + impl.unsubscribeFromPushTopic(topic); + } + /// Sets the preferred time interval between background fetches. This is only a /// preferred interval and is not guaranteed. Some platforms, like iOS, maintain sovereign /// control over when and if background fetches will be allowed. This number is used diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 1b8d1f4059..ba80faff36 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -97,6 +97,7 @@ import android.net.NetworkInfo; import android.os.Build; import android.os.Bundle; +import android.os.PersistableBundle; import android.os.Environment; import android.os.Handler; import android.os.IBinder; @@ -147,6 +148,13 @@ import com.codename1.media.MediaRecorderBuilder; import com.codename1.messaging.Message; import com.codename1.notifications.LocalNotification; +import com.codename1.notifications.NotificationChannelBuilder; +import com.codename1.notifications.NotificationPermissionCallback; +import com.codename1.notifications.NotificationPermissionRequest; +import com.codename1.notifications.NotificationPermissionResult; +import com.codename1.background.ForegroundService; +import com.codename1.background.WorkRequest; +import com.codename1.share.SharedContent; import com.codename1.payment.Purchase; import com.codename1.push.PushAction; import com.codename1.push.PushActionCategory; @@ -11260,6 +11268,11 @@ public void scheduleLocalNotification(LocalNotification notif, long firstTime, i PendingIntent pendingContentIntent = createPendingIntent(getContext(), 0, contentIntent); notificationIntent.putExtra(LocalNotificationPublisher.NOTIFICATION_INTENT, pendingContentIntent); + // carry the configured content intent as a template so the publisher can build + // a distinct per-action PendingIntent (with the action id and any remote input) + if (!notif.getActions().isEmpty()) { + notificationIntent.putExtra(LocalNotificationPublisher.NOTIFICATION_CONTENT_TEMPLATE, contentIntent); + } PendingIntent pendingIntent = getBroadcastPendingIntent(getContext(), 0, notificationIntent); @@ -11308,6 +11321,54 @@ static Bundle createBundleFromNotification(LocalNotification notif){ b.putString("NOTIF_SOUND", notif.getAlertSound()); b.putString("NOTIF_IMAGE", notif.getAlertImage()); b.putInt("NOTIF_NUMBER", notif.getBadgeNumber()); + b.putString("NOTIF_CHANNEL", notif.getChannelId()); + b.putString("NOTIF_GROUP", notif.getGroupId()); + b.putBoolean("NOTIF_GROUP_SUMMARY", notif.isGroupSummary()); + b.putBoolean("NOTIF_FULLSCREEN", notif.isFullScreenIntent()); + b.putBoolean("NOTIF_TIME_SENSITIVE", notif.isTimeSensitive()); + b.putBoolean("NOTIF_ONGOING", notif.isOngoing()); + b.putInt("NOTIF_PROGRESS_MAX", notif.getProgressMax()); + b.putInt("NOTIF_PROGRESS", notif.getProgress()); + b.putBoolean("NOTIF_PROGRESS_INDETERMINATE", notif.isProgressIndeterminate()); + b.putString("NOTIF_CUSTOM_VIEW", notif.getCustomView()); + java.util.List actions = notif.getActions(); + if (!actions.isEmpty()) { + ArrayList ids = new ArrayList(); + ArrayList titles = new ArrayList(); + ArrayList icons = new ArrayList(); + ArrayList placeholders = new ArrayList(); + ArrayList buttons = new ArrayList(); + for (LocalNotification.Action a : actions) { + ids.add(a.getId()); + titles.add(a.getTitle() == null ? "" : a.getTitle()); + icons.add(a.getIcon() == null ? "" : a.getIcon()); + placeholders.add(a.getTextInputPlaceholder() == null ? "" : a.getTextInputPlaceholder()); + buttons.add(a.getTextInputButtonText() == null ? "" : a.getTextInputButtonText()); + } + b.putStringArrayList("NOTIF_ACTION_IDS", ids); + b.putStringArrayList("NOTIF_ACTION_TITLES", titles); + b.putStringArrayList("NOTIF_ACTION_ICONS", icons); + b.putStringArrayList("NOTIF_ACTION_PLACEHOLDERS", placeholders); + b.putStringArrayList("NOTIF_ACTION_BUTTONS", buttons); + } + LocalNotification.MessagingStyle ms = notif.getMessagingStyle(); + if (ms != null) { + b.putString("NOTIF_MSG_SELF", ms.getSelfDisplayName()); + b.putString("NOTIF_MSG_TITLE", ms.getConversationTitle()); + b.putBoolean("NOTIF_MSG_GROUP", ms.isGroupConversation()); + ArrayList texts = new ArrayList(); + ArrayList senders = new ArrayList(); + long[] times = new long[ms.getMessages().size()]; + int i = 0; + for (LocalNotification.MessagingStyle.Message m : ms.getMessages()) { + texts.add(m.getText() == null ? "" : m.getText()); + senders.add(m.getSenderName() == null ? "" : m.getSenderName()); + times[i++] = m.getTimestamp(); + } + b.putStringArrayList("NOTIF_MSG_TEXTS", texts); + b.putStringArrayList("NOTIF_MSG_SENDERS", senders); + b.putLongArray("NOTIF_MSG_TIMES", times); + } return b; } @@ -11319,9 +11380,407 @@ static LocalNotification createNotificationFromBundle(Bundle b){ n.setAlertSound(b.getString("NOTIF_SOUND")); n.setAlertImage(b.getString("NOTIF_IMAGE")); n.setBadgeNumber(b.getInt("NOTIF_NUMBER")); + // new fields are guarded so bundles serialized by older builds still parse + if (b.containsKey("NOTIF_CHANNEL")) { + n.setChannelId(b.getString("NOTIF_CHANNEL")); + } + if (b.containsKey("NOTIF_GROUP")) { + n.setGroup(b.getString("NOTIF_GROUP")); + } + n.setGroupSummary(b.getBoolean("NOTIF_GROUP_SUMMARY", false)); + n.setFullScreenIntent(b.getBoolean("NOTIF_FULLSCREEN", false)); + n.setTimeSensitive(b.getBoolean("NOTIF_TIME_SENSITIVE", false)); + n.setOngoing(b.getBoolean("NOTIF_ONGOING", false)); + int progressMax = b.getInt("NOTIF_PROGRESS_MAX", 0); + if (progressMax > 0) { + n.setProgress(progressMax, b.getInt("NOTIF_PROGRESS", 0)); + } + n.setIndeterminateProgress(b.getBoolean("NOTIF_PROGRESS_INDETERMINATE", false)); + if (b.containsKey("NOTIF_CUSTOM_VIEW")) { + n.setCustomView(b.getString("NOTIF_CUSTOM_VIEW")); + } + ArrayList ids = b.getStringArrayList("NOTIF_ACTION_IDS"); + if (ids != null) { + ArrayList titles = b.getStringArrayList("NOTIF_ACTION_TITLES"); + ArrayList icons = b.getStringArrayList("NOTIF_ACTION_ICONS"); + ArrayList placeholders = b.getStringArrayList("NOTIF_ACTION_PLACEHOLDERS"); + ArrayList buttons = b.getStringArrayList("NOTIF_ACTION_BUTTONS"); + for (int i = 0; i < ids.size(); i++) { + String placeholder = placeholders != null ? emptyToNull(placeholders.get(i)) : null; + String button = buttons != null ? emptyToNull(buttons.get(i)) : null; + if (placeholder != null || button != null) { + n.addInputAction(ids.get(i), titles.get(i), placeholder, button); + } else { + String icon = icons != null ? emptyToNull(icons.get(i)) : null; + n.addAction(new LocalNotification.Action(ids.get(i), titles.get(i), icon)); + } + } + } + if (b.containsKey("NOTIF_MSG_SELF")) { + LocalNotification.MessagingStyle ms = n.asMessagingStyle(b.getString("NOTIF_MSG_SELF")); + ms.conversationTitle(b.getString("NOTIF_MSG_TITLE")); + ms.groupConversation(b.getBoolean("NOTIF_MSG_GROUP", false)); + ArrayList texts = b.getStringArrayList("NOTIF_MSG_TEXTS"); + ArrayList senders = b.getStringArrayList("NOTIF_MSG_SENDERS"); + long[] times = b.getLongArray("NOTIF_MSG_TIMES"); + if (texts != null) { + for (int i = 0; i < texts.size(); i++) { + ms.addMessage(texts.get(i), + times != null && i < times.length ? times[i] : 0, + senders != null ? emptyToNull(senders.get(i)) : null); + } + } + } return n; } + private static String emptyToNull(String s) { + return s == null || s.length() == 0 ? null : s; + } + + @Override + public void requestNotificationPermission(final NotificationPermissionRequest request, final NotificationPermissionCallback callback) { + if (callback == null) { + return; + } + final boolean granted; + if (android.os.Build.VERSION.SDK_INT >= 33) { + granted = checkForPermission("android.permission.POST_NOTIFICATIONS", "This is required to receive notifications", true); + } else { + // notifications are allowed by default below Android 13 + granted = true; + } + Display.getInstance().callSerially(new Runnable() { + public void run() { + callback.notificationPermissionResult(new NotificationPermissionResult(granted + ? NotificationPermissionResult.AuthorizationLevel.AUTHORIZED + : NotificationPermissionResult.AuthorizationLevel.DENIED)); + } + }); + } + + @Override + public void registerNotificationChannel(NotificationChannelBuilder builder) { + if (builder == null || android.os.Build.VERSION.SDK_INT < 26) { + return; + } + try { + NotificationManager nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); + Class clsChannel = Class.forName("android.app.NotificationChannel"); + Constructor ctor = clsChannel.getConstructor(String.class, CharSequence.class, int.class); + // map our 0..5 importance onto the platform IMPORTANCE_* (NONE=0 .. MAX=5) + Object channel = ctor.newInstance(builder.getId(), builder.getName(), builder.getImportance()); + if (builder.getDescription() != null) { + clsChannel.getMethod("setDescription", String.class).invoke(channel, builder.getDescription()); + } + clsChannel.getMethod("enableLights", boolean.class).invoke(channel, builder.isLightsEnabled()); + if (builder.isLightsEnabled()) { + clsChannel.getMethod("setLightColor", int.class).invoke(channel, builder.getLightColor()); + } + clsChannel.getMethod("enableVibration", boolean.class).invoke(channel, builder.isVibrationEnabled()); + if (builder.getVibrationPattern() != null) { + clsChannel.getMethod("setVibrationPattern", long[].class).invoke(channel, (Object) builder.getVibrationPattern()); + } + clsChannel.getMethod("setLockscreenVisibility", int.class).invoke(channel, builder.getLockscreenVisibility()); + clsChannel.getMethod("setShowBadge", boolean.class).invoke(channel, builder.isShowBadge()); + if (builder.getGroup() != null) { + clsChannel.getMethod("setGroup", String.class).invoke(channel, builder.getGroup()); + } + String sound = builder.getSound(); + if (sound != null && sound.length() > 0) { + sound = sound.toLowerCase(); + Uri uri = Uri.parse("android.resource://" + getContext().getApplicationInfo().packageName + "/raw" + + sound.substring(0, sound.indexOf("."))); + android.media.AudioAttributes attrs = new android.media.AudioAttributes.Builder() + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(android.media.AudioAttributes.USAGE_NOTIFICATION) + .build(); + clsChannel.getMethod("setSound", Uri.class, android.media.AudioAttributes.class).invoke(channel, uri, attrs); + } + nm.getClass().getMethod("createNotificationChannel", clsChannel).invoke(nm, channel); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void deleteNotificationChannel(String channelId) { + if (channelId == null || android.os.Build.VERSION.SDK_INT < 26) { + return; + } + try { + NotificationManager nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); + nm.getClass().getMethod("deleteNotificationChannel", String.class).invoke(nm, channelId); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void createNotificationChannelGroup(String groupId, String groupName) { + if (groupId == null || android.os.Build.VERSION.SDK_INT < 26) { + return; + } + try { + NotificationManager nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); + Class clsGroup = Class.forName("android.app.NotificationChannelGroup"); + Constructor ctor = clsGroup.getConstructor(String.class, CharSequence.class); + Object group = ctor.newInstance(groupId, groupName); + nm.getClass().getMethod("createNotificationChannelGroup", clsGroup).invoke(nm, group); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void subscribeToPushTopic(final String topic) { + invokeFirebaseTopic("subscribeToTopic", topic); + } + + @Override + public void unsubscribeFromPushTopic(final String topic) { + invokeFirebaseTopic("unsubscribeFromTopic", topic); + } + + private void invokeFirebaseTopic(String methodName, String topic) { + try { + Class cls = Class.forName("com.google.firebase.messaging.FirebaseMessaging"); + Object instance = cls.getMethod("getInstance").invoke(null); + cls.getMethod(methodName, String.class).invoke(instance, topic); + } catch (ClassNotFoundException notAvailable) { + com.codename1.io.Log.p("Firebase Cloud Messaging is not available; topic '" + topic + + "' subscription must be handled server side"); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public boolean isReceiveSharedContentSupported() { + return true; + } + + private static SharedContent pendingSharedContent; + + /// Delivers shared content received from another app. If the CN1 app instance is + /// running it is dispatched immediately on the EDT; otherwise it is held until the app + /// finishes starting and `#deliverPendingSharedContent()` is invoked. + static void deliverSharedContent(SharedContent content) { + if (content == null) { + return; + } + Object app = CodenameOneImplementation.getCurrentApplicationInstance(); + if (app != null && Display.isInitialized()) { + dispatchSharedContent(app, content); + } else { + pendingSharedContent = content; + } + } + + /// Invoked once the app has started to flush any shared content that arrived before the + /// app instance existed. + public static void deliverPendingSharedContent() { + SharedContent c = pendingSharedContent; + pendingSharedContent = null; + Object app = CodenameOneImplementation.getCurrentApplicationInstance(); + if (c != null && app != null) { + dispatchSharedContent(app, c); + } + } + + private static void dispatchSharedContent(final Object app, final SharedContent content) { + if (!(app instanceof com.codename1.system.Lifecycle)) { + return; + } + Display.getInstance().callSerially(new Runnable() { + public void run() { + ((com.codename1.system.Lifecycle) app).onReceivedSharedContent(content); + } + }); + } + + // ---- Constraint-aware background work (JobScheduler) ---- + + @Override + public boolean isBackgroundWorkSupported() { + return android.os.Build.VERSION.SDK_INT >= 21; + } + + private static int jobIdFor(String id) { + return (id.hashCode() & 0x7fffffff) % 1000000 + 1000; + } + + @Override + public void scheduleBackgroundWork(WorkRequest request) { + if (android.os.Build.VERSION.SDK_INT < 21) { + return; + } + try { + android.app.job.JobScheduler scheduler = + (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); + android.content.ComponentName component = + new android.content.ComponentName(getContext(), CodenameOneJobService.class); + android.app.job.JobInfo.Builder builder = + new android.app.job.JobInfo.Builder(jobIdFor(request.getId()), component); + + if (request.isRequiresUnmeteredNetwork()) { + builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_UNMETERED); + } else if (request.isRequiresNetwork()) { + builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_ANY); + } + builder.setRequiresCharging(request.isRequiresCharging()); + if (android.os.Build.VERSION.SDK_INT >= 23) { + builder.setRequiresDeviceIdle(request.isRequiresIdle()); + } + if (android.os.Build.VERSION.SDK_INT >= 26) { + builder.setRequiresBatteryNotLow(request.isRequiresBatteryNotLow()); + } + if (request.isPeriodic()) { + builder.setPeriodic(Math.max(15 * 60 * 1000L, request.getMinIntervalMillis())); + } else { + if (request.getInitialDelayMillis() > 0) { + builder.setMinimumLatency(request.getInitialDelayMillis()); + } + builder.setOverrideDeadline(Math.max(request.getInitialDelayMillis(), 0) + 60 * 60 * 1000L); + } + + PersistableBundle extras = new PersistableBundle(); + extras.putString(CodenameOneJobService.EXTRA_WORKER_CLASS, request.getWorkerClass()); + extras.putString(CodenameOneJobService.EXTRA_WORK_ID, request.getId()); + for (java.util.Map.Entry e : request.getInputData().entrySet()) { + extras.putString(CodenameOneJobService.INPUT_PREFIX + e.getKey(), e.getValue()); + } + builder.setExtras(extras); + scheduler.schedule(builder.build()); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void cancelBackgroundWork(String workId) { + if (android.os.Build.VERSION.SDK_INT < 21) { + return; + } + try { + android.app.job.JobScheduler scheduler = + (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); + scheduler.cancel(jobIdFor(workId)); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public boolean isBackgroundProcessingSupported() { + return android.os.Build.VERSION.SDK_INT >= 21; + } + + @Override + public void scheduleBackgroundProcessing(String id, long earliestBeginEpochMs, boolean requiresNetwork, boolean requiresPower, Runnable task) { + if (android.os.Build.VERSION.SDK_INT < 21 || task == null) { + return; + } + try { + CodenameOneJobService.registerProcessingRunnable(id, task); + android.app.job.JobScheduler scheduler = + (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); + android.content.ComponentName component = + new android.content.ComponentName(getContext(), CodenameOneJobService.class); + android.app.job.JobInfo.Builder builder = + new android.app.job.JobInfo.Builder(jobIdFor("proc-" + id), component); + if (requiresNetwork) { + builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_ANY); + } + builder.setRequiresCharging(requiresPower); + long delay = earliestBeginEpochMs <= 0 ? 0 : Math.max(0, earliestBeginEpochMs - System.currentTimeMillis()); + if (delay > 0) { + builder.setMinimumLatency(delay); + } + builder.setOverrideDeadline(delay + 60 * 60 * 1000L); + PersistableBundle extras = new PersistableBundle(); + extras.putString(CodenameOneJobService.EXTRA_PROCESSING_ID, id); + builder.setExtras(extras); + scheduler.schedule(builder.build()); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void cancelBackgroundProcessing(String id) { + CodenameOneJobService.unregisterProcessingRunnable(id); + if (android.os.Build.VERSION.SDK_INT < 21) { + return; + } + try { + android.app.job.JobScheduler scheduler = + (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); + scheduler.cancel(jobIdFor("proc-" + id)); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + // ---- Foreground service ---- + + @Override + public boolean isForegroundServiceSupported() { + return true; + } + + @Override + public Object startForegroundService(String channelId, String title, String body, String iconName, ForegroundService.Task task, ForegroundService handle) { + int token = CodenameOneForegroundService.registerTask(task, handle, channelId, title, body, iconName); + try { + Intent intent = new Intent(getContext(), CodenameOneForegroundService.class); + intent.setAction(CodenameOneForegroundService.ACTION_START); + intent.putExtra(CodenameOneForegroundService.EXTRA_TOKEN, token); + intent.putExtra(CodenameOneForegroundService.EXTRA_CHANNEL, channelId); + intent.putExtra(CodenameOneForegroundService.EXTRA_TITLE, title); + intent.putExtra(CodenameOneForegroundService.EXTRA_BODY, body); + intent.putExtra(CodenameOneForegroundService.EXTRA_ICON, iconName); + if (android.os.Build.VERSION.SDK_INT >= 26) { + getContext().startForegroundService(intent); + } else { + getContext().startService(intent); + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + return Integer.valueOf(token); + } + + @Override + public void updateForegroundServiceNotification(Object nativeHandle, String title, String body) { + try { + Intent intent = new Intent(getContext(), CodenameOneForegroundService.class); + intent.setAction(CodenameOneForegroundService.ACTION_UPDATE); + if (nativeHandle instanceof Integer) { + intent.putExtra(CodenameOneForegroundService.EXTRA_TOKEN, ((Integer) nativeHandle).intValue()); + } + intent.putExtra(CodenameOneForegroundService.EXTRA_TITLE, title); + intent.putExtra(CodenameOneForegroundService.EXTRA_BODY, body); + getContext().startService(intent); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void stopForegroundService(Object nativeHandle) { + try { + Intent intent = new Intent(getContext(), CodenameOneForegroundService.class); + intent.setAction(CodenameOneForegroundService.ACTION_STOP); + if (nativeHandle instanceof Integer) { + intent.putExtra(CodenameOneForegroundService.EXTRA_TOKEN, ((Integer) nativeHandle).intValue()); + } + getContext().startService(intent); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + boolean brokenGaussian; public Image gaussianBlurImage(Image image, float radius) { try { diff --git a/Ports/Android/src/com/codename1/impl/android/CodenameOneForegroundService.java b/Ports/Android/src/com/codename1/impl/android/CodenameOneForegroundService.java new file mode 100644 index 0000000000..67a9839368 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/CodenameOneForegroundService.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import android.support.v4.app.NotificationCompat; +import com.codename1.background.ForegroundService; +import java.util.HashMap; +import java.util.Map; + +/// Android foreground service backing `com.codename1.background.ForegroundService`. Shows +/// an ongoing notification and runs the supplied task on a background thread until the task +/// returns or `stop()` is called. Tasks are live Java objects, so they are held in a static +/// registry keyed by an integer token passed through the start intent. +public class CodenameOneForegroundService extends Service { + + static final String ACTION_START = "com.codename1.impl.android.FGS_START"; + static final String ACTION_UPDATE = "com.codename1.impl.android.FGS_UPDATE"; + static final String ACTION_STOP = "com.codename1.impl.android.FGS_STOP"; + static final String EXTRA_TOKEN = "token"; + static final String EXTRA_CHANNEL = "channel"; + static final String EXTRA_TITLE = "title"; + static final String EXTRA_BODY = "body"; + static final String EXTRA_ICON = "icon"; + + private static int nextToken = 1; + private static final Map REGISTRATIONS = new HashMap(); + + private static class Registration { + ForegroundService.Task task; + ForegroundService handle; + String channelId; + String title; + String body; + String iconName; + boolean started; + } + + static synchronized int registerTask(ForegroundService.Task task, ForegroundService handle, String channelId, String title, String body, String iconName) { + int token = nextToken++; + Registration r = new Registration(); + r.task = task; + r.handle = handle; + r.channelId = channelId; + r.title = title; + r.body = body; + r.iconName = iconName; + REGISTRATIONS.put(token, r); + return token; + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + return START_NOT_STICKY; + } + String action = intent.getAction(); + final int token = intent.getIntExtra(EXTRA_TOKEN, -1); + if (ACTION_STOP.equals(action)) { + stopForeground(true); + stopSelf(); + REGISTRATIONS.remove(token); + return START_NOT_STICKY; + } + if (ACTION_UPDATE.equals(action)) { + String title = intent.getStringExtra(EXTRA_TITLE); + String body = intent.getStringExtra(EXTRA_BODY); + Registration r = REGISTRATIONS.get(token); + String channel = r != null ? r.channelId : intent.getStringExtra(EXTRA_CHANNEL); + String icon = r != null ? r.iconName : intent.getStringExtra(EXTRA_ICON); + NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + nm.notify(token, buildNotification(channel, title, body, icon)); + return START_NOT_STICKY; + } + + // ACTION_START + final Registration reg = REGISTRATIONS.get(token); + String channel = reg != null ? reg.channelId : intent.getStringExtra(EXTRA_CHANNEL); + String title = reg != null ? reg.title : intent.getStringExtra(EXTRA_TITLE); + String body = reg != null ? reg.body : intent.getStringExtra(EXTRA_BODY); + String icon = reg != null ? reg.iconName : intent.getStringExtra(EXTRA_ICON); + startForeground(token, buildNotification(channel, title, body, icon)); + if (reg != null && reg.task != null && !reg.started) { + reg.started = true; + final ForegroundService.Task task = reg.task; + final ForegroundService handle = reg.handle; + new Thread(new Runnable() { + public void run() { + try { + task.run(handle); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } finally { + stopForeground(true); + stopSelf(); + REGISTRATIONS.remove(token); + } + } + }, "cn1-foreground-service").start(); + } + return START_NOT_STICKY; + } + + private Notification buildNotification(String channelId, String title, String body, String iconName) { + int smallIcon = getResources().getIdentifier(iconName != null ? iconName : "ic_stat_notify", "drawable", getApplicationInfo().packageName); + if (smallIcon == 0) { + smallIcon = getResources().getIdentifier("icon", "drawable", getApplicationInfo().packageName); + } + NotificationCompat.Builder builder = new NotificationCompat.Builder(this); + builder.setContentTitle(title); + builder.setContentText(body); + builder.setOngoing(true); + builder.setSmallIcon(smallIcon); + if (channelId != null) { + try { + builder.getClass().getMethod("setChannelId", String.class).invoke(builder, channelId); + } catch (Throwable ignore) { + } + } + AndroidImplementation.setNotificationChannel((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE), builder, this); + return builder.build(); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/CodenameOneJobService.java b/Ports/Android/src/com/codename1/impl/android/CodenameOneJobService.java new file mode 100644 index 0000000000..2011bd5ea0 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/CodenameOneJobService.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android; + +import android.app.job.JobParameters; +import android.app.job.JobService; +import android.os.PersistableBundle; +import android.util.Log; +import com.codename1.background.BackgroundWorker; +import com.codename1.ui.Display; +import java.util.HashMap; +import java.util.Map; + +/// JobScheduler entry point for constraint-aware background work scheduled through +/// `com.codename1.background.BackgroundWork`. A fresh CN1 context is started if the app +/// process was not already running, the worker class is reconstructed reflectively and +/// run, and the job is then finished (rescheduled when the worker requests a retry). +/// +/// One-shot processing tasks scheduled via `com.codename1.background.BackgroundTask` carry +/// a live Runnable in a static registry; if the process was killed and cold launched the +/// runnable is gone and the job is a no-op (use a BackgroundWorker for cold-launch safe +/// work). +public class CodenameOneJobService extends JobService { + + static final String EXTRA_WORKER_CLASS = "cn1.workerClass"; + static final String EXTRA_WORK_ID = "cn1.workId"; + static final String EXTRA_PROCESSING_ID = "cn1.processingId"; + static final String INPUT_PREFIX = "cn1.in."; + + private static final Map PROCESSING_RUNNABLES = new HashMap(); + + static void registerProcessingRunnable(String id, Runnable r) { + synchronized (PROCESSING_RUNNABLES) { + PROCESSING_RUNNABLES.put(id, r); + } + } + + static void unregisterProcessingRunnable(String id) { + synchronized (PROCESSING_RUNNABLES) { + PROCESSING_RUNNABLES.remove(id); + } + } + + @Override + public boolean onStartJob(final JobParameters params) { + final PersistableBundle extras = params.getExtras(); + final String processingId = extras == null ? null : extras.getString(EXTRA_PROCESSING_ID); + if (processingId != null) { + Runnable r; + synchronized (PROCESSING_RUNNABLES) { + r = PROCESSING_RUNNABLES.remove(processingId); + } + if (r == null) { + Log.d("CN1", "Background processing task '" + processingId + "' has no live runnable (process was relaunched); skipping"); + return false; + } + final Runnable task = r; + new Thread(new Runnable() { + public void run() { + try { + task.run(); + } catch (Throwable t) { + Log.e("CN1", "background processing error", t); + } finally { + jobFinished(params, false); + } + } + }, "cn1-background-processing").start(); + return true; + } + + final String workerClass = extras == null ? null : extras.getString(EXTRA_WORKER_CLASS); + final String workId = extras == null ? null : extras.getString(EXTRA_WORK_ID); + if (workerClass == null) { + return false; + } + final Map input = new HashMap(); + if (extras != null) { + for (String key : extras.keySet()) { + if (key.startsWith(INPUT_PREFIX)) { + input.put(key.substring(INPUT_PREFIX.length()), extras.getString(key)); + } + } + } + + new Thread(new Runnable() { + public void run() { + boolean startedContext = false; + if (!Display.isInitialized()) { + startedContext = true; + AndroidImplementation.startContext(CodenameOneJobService.this); + } + final boolean fStarted = startedContext; + final boolean[] retry = new boolean[]{false}; + final Object lock = new Object(); + final boolean[] done = new boolean[]{false}; + try { + Class cls = Class.forName(workerClass); + BackgroundWorker worker = (BackgroundWorker) cls.newInstance(); + long deadline = System.currentTimeMillis() + 9 * 60 * 1000L; + worker.performWork(workId, input, deadline, new com.codename1.util.Callback() { + public void onSucess(Boolean value) { + synchronized (lock) { + retry[0] = (value != null && !value.booleanValue()); + done[0] = true; + lock.notifyAll(); + } + } + public void onError(Object sender, Throwable err, int errorCode, String errorMessage) { + Log.e("CN1", "background worker error", err); + synchronized (lock) { + retry[0] = true; + done[0] = true; + lock.notifyAll(); + } + } + }); + synchronized (lock) { + long waitUntil = System.currentTimeMillis() + 9 * 60 * 1000L; + while (!done[0] && System.currentTimeMillis() < waitUntil) { + try { + lock.wait(waitUntil - System.currentTimeMillis()); + } catch (InterruptedException ie) { + break; + } + } + } + } catch (Throwable t) { + Log.e("CN1", "Failed to run background worker " + workerClass, t); + } finally { + if (fStarted) { + AndroidImplementation.stopContext(CodenameOneJobService.this); + } + jobFinished(params, retry[0]); + } + } + }, "cn1-background-work").start(); + return true; + } + + @Override + public boolean onStopJob(JobParameters params) { + // request a reschedule if the system stopped us early + return true; + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/CodenameOneShareReceiverActivity.java b/Ports/Android/src/com/codename1/impl/android/CodenameOneShareReceiverActivity.java new file mode 100644 index 0000000000..7e67ef699d --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/CodenameOneShareReceiverActivity.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import com.codename1.share.SharedContent; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +/// Receives content shared into the app from other apps through the Android share sheet +/// (ACTION_SEND / ACTION_SEND_MULTIPLE). Stream payloads are copied into app storage so the +/// app sees a stable file path, a `com.codename1.share.SharedContent` is built and delivered +/// to the running app's `onReceivedSharedContent`, then the main activity is brought forward. +/// +/// This activity is registered with the appropriate intent filter by the build (see the +/// android.shareFilter build hint). +public class CodenameOneShareReceiverActivity extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + try { + handleShareIntent(getIntent()); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + launchMainActivity(); + finish(); + } + + private void handleShareIntent(Intent intent) { + if (intent == null) { + return; + } + String action = intent.getAction(); + String type = intent.getType(); + SharedContent.Builder b = SharedContent.builder(); + String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT); + if (subject != null) { + b.subject(subject); + } + boolean any = false; + + if (Intent.ACTION_SEND.equals(action)) { + CharSequence text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT); + if (text != null) { + addTextOrUrl(b, text.toString()); + any = true; + } + Uri stream = intent.getParcelableExtra(Intent.EXTRA_STREAM); + if (stream != null) { + any = addStream(b, stream, type) || any; + } + } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { + ArrayList streams = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (streams != null) { + for (Uri stream : streams) { + any = addStream(b, stream, type) || any; + } + } + } + + if (any) { + AndroidImplementation.deliverSharedContent(b.build()); + } + } + + private void addTextOrUrl(SharedContent.Builder b, String text) { + String trimmed = text.trim(); + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + b.addUrl(trimmed); + } else { + b.addText(text); + } + } + + private boolean addStream(SharedContent.Builder b, Uri uri, String type) { + InputStream in = null; + OutputStream os = null; + try { + String mime = type; + if (mime == null) { + mime = getContentResolver().getType(uri); + } + String name = queryDisplayName(uri); + File dir = new File(getFilesDir(), "shared"); + if (!dir.exists() && !dir.mkdirs()) { + com.codename1.io.Log.p("Failed to create shared content directory " + dir.getAbsolutePath()); + return false; + } + File out = new File(dir, name); + in = getContentResolver().openInputStream(uri); + if (in == null) { + return false; + } + os = new FileOutputStream(out); + byte[] buf = new byte[8192]; + int r; + while ((r = in.read(buf)) > 0) { + os.write(buf, 0, r); + } + os.close(); + os = null; + String path = "file://" + out.getAbsolutePath(); + if (mime != null && mime.startsWith("image/")) { + b.addImage(mime, path, name); + } else { + b.addFile(mime, path, name); + } + return true; + } catch (Throwable t) { + com.codename1.io.Log.e(t); + return false; + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException ignore) { + } + } + if (os != null) { + try { + os.close(); + } catch (IOException ignore) { + } + } + } + } + + private String queryDisplayName(Uri uri) { + String name = uri.getLastPathSegment(); + try { + android.database.Cursor c = getContentResolver().query(uri, null, null, null, null); + if (c != null) { + try { + int idx = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME); + if (idx >= 0 && c.moveToFirst()) { + String dn = c.getString(idx); + if (dn != null) { + name = dn; + } + } + } finally { + c.close(); + } + } + } catch (Throwable ignore) { + } + if (name == null || name.length() == 0) { + name = "shared-" + System.currentTimeMillis(); + } + return name.replaceAll("[^a-zA-Z0-9._-]", "_"); + } + + private void launchMainActivity() { + try { + Intent launch = getPackageManager().getLaunchIntentForPackage(getApplicationInfo().packageName); + if (launch != null) { + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + startActivity(launch); + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/LocalNotificationPublisher.java b/Ports/Android/src/com/codename1/impl/android/LocalNotificationPublisher.java index 11b122bceb..389e1b7f6f 100644 --- a/Ports/Android/src/com/codename1/impl/android/LocalNotificationPublisher.java +++ b/Ports/Android/src/com/codename1/impl/android/LocalNotificationPublisher.java @@ -35,6 +35,8 @@ import android.util.Log; import com.codename1.background.BackgroundFetch; +import com.codename1.impl.android.compat.app.NotificationCompatWrapper; +import com.codename1.impl.android.compat.app.RemoteInputWrapper; import com.codename1.notifications.LocalNotification; import com.codename1.ui.Display; import java.io.IOException; @@ -51,6 +53,7 @@ public class LocalNotificationPublisher extends BroadcastReceiver { public static String NOTIFICATION = "notification"; public static String NOTIFICATION_INTENT = "notification-intent"; public static String BACKGROUND_FETCH_INTENT = "background-fetch-intent"; + public static String NOTIFICATION_CONTENT_TEMPLATE = "notification-content-template"; public void onReceive(Context context, Intent intent) { //Fire the notification to the display @@ -76,7 +79,8 @@ public void onReceive(Context context, Intent intent) { Log.d("Codename One", "BackgroundFetch intent was null"); } } else { - Notification notification = createAndroidNotification(context, notif, content); + Intent contentTemplate = extras.getParcelable(NOTIFICATION_CONTENT_TEMPLATE); + Notification notification = createAndroidNotification(context, notif, content, contentTemplate); notification.when = System.currentTimeMillis(); try{ int notifId = Integer.parseInt(notif.getId()); @@ -88,7 +92,7 @@ public void onReceive(Context context, Intent intent) { } } - private Notification createAndroidNotification(Context context, LocalNotification localNotif, PendingIntent content) { + private Notification createAndroidNotification(Context context, LocalNotification localNotif, PendingIntent content, Intent contentTemplate) { Context ctx = context; int smallIcon = ctx.getResources().getIdentifier("ic_stat_notify", "drawable", ctx.getApplicationInfo().packageName); int icon = ctx.getResources().getIdentifier("icon", "drawable", ctx.getApplicationInfo().packageName); @@ -127,6 +131,101 @@ private Notification createAndroidNotification(Context context, LocalNotificatio } builder.setSmallIcon(smallIcon); builder.setContentIntent(content); + + // grouping + if (localNotif.getGroupId() != null) { + builder.setGroup(localNotif.getGroupId()); + builder.setGroupSummary(localNotif.isGroupSummary()); + } + // ongoing (cannot be dismissed) + if (localNotif.isOngoing()) { + builder.setOngoing(true); + } + // progress bar + if (localNotif.getProgressMax() > 0 || localNotif.isProgressIndeterminate()) { + builder.setProgress(Math.max(1, localNotif.getProgressMax()), localNotif.getProgress(), localNotif.isProgressIndeterminate()); + } + // full screen intent (high priority interruptions) + if (localNotif.isFullScreenIntent() && content != null) { + builder.setFullScreenIntent(content, true); + builder.setPriority(NotificationCompat.PRIORITY_HIGH); + } + // time sensitive: Android has no exact equivalent, approximate with category + priority + if (localNotif.isTimeSensitive()) { + builder.setCategory(NotificationCompat.CATEGORY_MESSAGE); + builder.setPriority(NotificationCompat.PRIORITY_HIGH); + } + // messaging (conversation) style + LocalNotification.MessagingStyle ms = localNotif.getMessagingStyle(); + if (ms != null && !ms.getMessages().isEmpty()) { + NotificationCompat.MessagingStyle style = new NotificationCompat.MessagingStyle( + ms.getSelfDisplayName() == null ? "" : ms.getSelfDisplayName()); + if (ms.getConversationTitle() != null) { + style.setConversationTitle(ms.getConversationTitle()); + } + for (LocalNotification.MessagingStyle.Message m : ms.getMessages()) { + CharSequence sender = m.getSenderName(); + style.addMessage(m.getText(), m.getTimestamp(), sender); + } + builder.setStyle(style); + } + // explicit channel id on the notification (Android O+), via reflection to stay + // compatible with the support library baseline + if (localNotif.getChannelId() != null) { + try { + builder.getClass().getMethod("setChannelId", String.class).invoke(builder, localNotif.getChannelId()); + } catch (Throwable ignore) { + } + } + + // action buttons (with optional inline quick-reply text input) + if (!localNotif.getActions().isEmpty() && contentTemplate != null) { + int requestCode = 1; + for (LocalNotification.Action a : localNotif.getActions()) { + Intent actionIntent = (Intent) contentTemplate.clone(); + actionIntent.putExtra("LocalNotificationActionId", a.getId()); + actionIntent.putExtra("LocalNotificationActionTitle", a.getTitle() == null ? "" : a.getTitle()); + // make the per-action intent unique so the PendingIntents don't collide + actionIntent.setData(android.net.Uri.parse("http://codenameone.com/a?LocalNotificationID=" + + android.net.Uri.encode(localNotif.getId()) + "&action=" + android.net.Uri.encode(a.getId()))); + PendingIntent actionPending = AndroidImplementation.createMutablePendingIntent(context, requestCode++, actionIntent); + // The action icon is a drawable resource NAME (Codename One has no R.drawable + // int constants). Resolve it by name against the generated res/drawable; an + // unknown / null name yields 0 (no icon), which the action button tolerates. + int iconId = 0; + String iconName = a.getIcon(); + if (iconName != null && iconName.length() > 0) { + if (iconName.startsWith("/")) { + iconName = iconName.substring(1); + } + int dot = iconName.lastIndexOf('.'); + if (dot > 0) { + iconName = iconName.substring(0, dot); + } + iconId = ctx.getResources().getIdentifier(iconName, "drawable", ctx.getApplicationInfo().packageName); + } + try { + if (NotificationCompatWrapper.ActionWrapper.BuilderWrapper.isSupported()) { + NotificationCompatWrapper.ActionWrapper.BuilderWrapper actionBuilder = + new NotificationCompatWrapper.ActionWrapper.BuilderWrapper(iconId, a.getTitle(), actionPending); + if (a.isTextInput() && RemoteInputWrapper.isSupported()) { + RemoteInputWrapper.BuilderWrapper remoteInputBuilder = + new RemoteInputWrapper.BuilderWrapper(a.getId() + "$Result"); + if (a.getTextInputPlaceholder() != null) { + remoteInputBuilder.setLabel(a.getTextInputPlaceholder()); + } + actionBuilder.addRemoteInput(remoteInputBuilder.build()); + } + new NotificationCompatWrapper.BuilderWrapper(builder).addAction(actionBuilder.build()); + } else { + builder.addAction(iconId, a.getTitle(), actionPending); + } + } catch (Exception ex) { + ex.printStackTrace(); + } + } + } + AndroidImplementation.setNotificationChannel((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE), builder, context); String sound = localNotif.getAlertSound(); if (sound != null && sound.length() > 0) { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java b/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java index 5483d15b84..c2512d605b 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java @@ -264,7 +264,8 @@ public void run() { System.exit(1); } app = c.newInstance(); - + CodenameOneImplementation.setCurrentApplicationInstance(app); + if(app instanceof PushCallback) { CodenameOneImplementation.setPushCallback((PushCallback)app); } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 96646b367d..4717a8a2ae 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -104,6 +104,16 @@ import com.codename1.media.MediaManager; import com.codename1.media.MediaRecorderBuilder; import com.codename1.notifications.LocalNotification; +import com.codename1.notifications.LocalNotificationCallback; +import com.codename1.notifications.NotificationChannelBuilder; +import com.codename1.notifications.NotificationPermissionCallback; +import com.codename1.notifications.NotificationPermissionRequest; +import com.codename1.notifications.NotificationPermissionResult; +import com.codename1.background.BackgroundWorker; +import com.codename1.background.ForegroundService; +import com.codename1.background.WorkRequest; +import com.codename1.share.SharedContent; +import com.codename1.push.PushContent; import com.codename1.payment.Product; import com.codename1.payment.Purchase; import com.codename1.payment.Receipt; @@ -984,7 +994,8 @@ private static long getRepeatPeriod(int repeat) { private Map localNotifications = new HashMap(); private java.util.Timer localNotificationsTimer; - + private final Map notificationChannels = new HashMap(); + @Override public void scheduleLocalNotification(final LocalNotification notif, long firstTime, int repeat) { if (isSimulator()) { @@ -993,34 +1004,7 @@ public void scheduleLocalNotification(final LocalNotification notif, long firstT } TimerTask task = new TimerTask() { public void run() { - if (!SystemTray.isSupported()) { - System.out.println("Local notification not supported on this OS!!!"); - return; - } - if (isMinimized()) { - SystemTray sysTray = SystemTray.getSystemTray(); - TrayIcon tray = new TrayIcon(Toolkit.getDefaultToolkit().getImage("/CodenameOne_Small.png")); - tray.setImageAutoSize(true); - tray.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - Display.getInstance().callSerially(new Runnable() { - public void run() { - Executor.startApp(); - minimized = false; - } - }); - canvas.setEnabled(true); - pause.setText("Pause App"); - } - }); - try { - sysTray.add(tray); - tray.displayMessage(notif.getAlertTitle(), notif.getAlertBody(), TrayIcon.MessageType.INFO); - } catch (Exception ex) { - ex.printStackTrace(); - } - } + SimulatorNotifications.show(JavaSEPort.this, notif); } }; if (localNotifications.containsKey(notif.getId())) { @@ -1044,8 +1028,309 @@ public void cancelLocalNotification(String notificationId) { n.cancel(); localNotifications.remove(notificationId); } + SimulatorNotifications.dismiss(notificationId); + } + } + + /// Returns the channel registered for the given id, or null. Used by the simulator + /// notification panel and the channel inspector. + NotificationChannelBuilder getSimulatorChannel(String id) { + return id == null ? null : notificationChannels.get(id); + } + + java.util.Collection getSimulatorChannels() { + return notificationChannels.values(); + } + + /// Falls back to the legacy system tray notification (used when the simulator window + /// is minimized and the rich panel cannot be shown over the canvas). + void showTrayNotification(final LocalNotification notif) { + if (!SystemTray.isSupported()) { + System.out.println("Local notification not supported on this OS!!!"); + return; + } + SystemTray sysTray = SystemTray.getSystemTray(); + TrayIcon tray = new TrayIcon(Toolkit.getDefaultToolkit().getImage("/CodenameOne_Small.png")); + tray.setImageAutoSize(true); + tray.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + Executor.startApp(); + minimized = false; + } + }); + canvas.setEnabled(true); + if (pause != null) { + pause.setText("Pause App"); + } + } + }); + try { + sysTray.add(tray); + tray.displayMessage(notif.getAlertTitle(), notif.getAlertBody(), TrayIcon.MessageType.INFO); + } catch (Exception ex) { + ex.printStackTrace(); + } + } + + /// Dispatches a local notification (and any selected action / text reply) to the + /// running app's LocalNotificationCallback, mirroring the device behavior. Called by + /// the simulator notification panel when the user taps the notification or an action. + void dispatchLocalNotification(String notificationId, String actionId, String actionTitle, String textResponse) { + if (actionId != null) { + PushContent.setActionId(actionId); + } + if (actionTitle != null) { + PushContent.setActionTitle(actionTitle); + } + if (textResponse != null) { + PushContent.setTextResponse(textResponse); + } + Object app = CodenameOneImplementation.getCurrentApplicationInstance(); + final String fid = notificationId; + if (app instanceof LocalNotificationCallback) { + final LocalNotificationCallback cb = (LocalNotificationCallback) app; + Display.getInstance().callSerially(new Runnable() { + public void run() { + cb.localNotificationReceived(fid); + } + }); } } + + @Override + public void requestNotificationPermission(final NotificationPermissionRequest request, final NotificationPermissionCallback callback) { + if (callback == null) { + return; + } + Display.getInstance().callSerially(new Runnable() { + public void run() { + callback.notificationPermissionResult(new NotificationPermissionResult(NotificationPermissionResult.AuthorizationLevel.AUTHORIZED)); + } + }); + } + + @Override + public void registerNotificationChannel(NotificationChannelBuilder builder) { + if (builder != null) { + notificationChannels.put(builder.getId(), builder); + } + } + + @Override + public void deleteNotificationChannel(String channelId) { + notificationChannels.remove(channelId); + } + + @Override + public void createNotificationChannelGroup(String groupId, String groupName) { + // channel groups are presentational only in the simulator + } + + @Override + public boolean isReceiveSharedContentSupported() { + return isSimulator(); + } + + // ---- Constraint-aware background work (simulator) ---- + + boolean simNetworkAvailable = true; + boolean simCharging = true; + boolean simDeviceIdle = true; + boolean simBatteryNotLow = true; + private final Map scheduledWork = new java.util.LinkedHashMap(); + private final Map scheduledWorkTasks = new HashMap(); + private java.util.Timer backgroundWorkTimer; + + @Override + public boolean isBackgroundWorkSupported() { + return isSimulator(); + } + + Map getScheduledWork() { + return scheduledWork; + } + + boolean constraintsSatisfied(WorkRequest r) { + if ((r.isRequiresNetwork() || r.isRequiresUnmeteredNetwork()) && !simNetworkAvailable) { + return false; + } + if (r.isRequiresCharging() && !simCharging) { + return false; + } + if (r.isRequiresIdle() && !simDeviceIdle) { + return false; + } + if (r.isRequiresBatteryNotLow() && !simBatteryNotLow) { + return false; + } + return true; + } + + @Override + public void scheduleBackgroundWork(final WorkRequest request) { + if (!isSimulator()) { + return; + } + scheduledWork.put(request.getId(), request); + if (backgroundWorkTimer == null) { + backgroundWorkTimer = new java.util.Timer(); + } + TimerTask existing = scheduledWorkTasks.remove(request.getId()); + if (existing != null) { + existing.cancel(); + } + TimerTask task = new TimerTask() { + public void run() { + if (constraintsSatisfied(request)) { + runWorkerNow(request); + } else { + System.out.println("[BackgroundWork] '" + request.getId() + "' deferred: constraints not satisfied"); + } + } + }; + scheduledWorkTasks.put(request.getId(), task); + long delay = Math.max(0, request.getInitialDelayMillis()); + if (request.isPeriodic()) { + backgroundWorkTimer.schedule(task, delay == 0 ? 1000 : delay, Math.max(1000, request.getMinIntervalMillis())); + } else { + backgroundWorkTimer.schedule(task, delay == 0 ? 500 : delay); + } + } + + void runWorkerNow(final WorkRequest request) { + new Thread(new Runnable() { + public void run() { + try { + Class cls = Class.forName(request.getWorkerClass()); + final BackgroundWorker worker = (BackgroundWorker) cls.newInstance(); + long deadline = System.currentTimeMillis() + 30000; + worker.performWork(request.getId(), request.getInputData(), deadline, new com.codename1.util.Callback() { + public void onSucess(Boolean value) { + System.out.println("[BackgroundWork] '" + request.getId() + "' completed: success=" + value); + } + public void onError(Object sender, Throwable err, int errorCode, String errorMessage) { + com.codename1.io.Log.e(err); + } + }); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + }, "cn1-sim-background-work").start(); + } + + @Override + public void cancelBackgroundWork(String workId) { + scheduledWork.remove(workId); + TimerTask t = scheduledWorkTasks.remove(workId); + if (t != null) { + t.cancel(); + } + } + + // ---- Background processing (simulator) ---- + + private final Map processingTasks = new HashMap(); + + @Override + public boolean isBackgroundProcessingSupported() { + return isSimulator(); + } + + @Override + public void scheduleBackgroundProcessing(String id, long earliestBeginEpochMs, boolean requiresNetwork, boolean requiresPower, final Runnable task) { + if (!isSimulator() || task == null) { + return; + } + if (backgroundWorkTimer == null) { + backgroundWorkTimer = new java.util.Timer(); + } + TimerTask existing = processingTasks.remove(id); + if (existing != null) { + existing.cancel(); + } + final boolean reqNet = requiresNetwork; + final boolean reqPow = requiresPower; + final String fid = id; + TimerTask t = new TimerTask() { + public void run() { + if ((reqNet && !simNetworkAvailable) || (reqPow && !simCharging)) { + System.out.println("[BackgroundTask] '" + fid + "' deferred: constraints not satisfied"); + return; + } + new Thread(task, "cn1-sim-background-task").start(); + } + }; + processingTasks.put(id, t); + long delay = earliestBeginEpochMs <= 0 ? 500 : Math.max(0, earliestBeginEpochMs - System.currentTimeMillis()); + backgroundWorkTimer.schedule(t, delay); + } + + @Override + public void cancelBackgroundProcessing(String id) { + TimerTask t = processingTasks.remove(id); + if (t != null) { + t.cancel(); + } + } + + // ---- Foreground service (simulator) ---- + + @Override + public boolean isForegroundServiceSupported() { + return isSimulator(); + } + + @Override + public Object startForegroundService(String channelId, String title, String body, String iconName, final ForegroundService.Task task, final ForegroundService handle) { + final String[] text = new String[]{title, body}; + System.out.println("[ForegroundService] started: " + title + " - " + body); + SimulatorNotifications.setForegroundServiceStatus(title + " - " + body); + if (task != null) { + new Thread(new Runnable() { + public void run() { + try { + task.run(handle); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } finally { + SimulatorNotifications.setForegroundServiceStatus(null); + System.out.println("[ForegroundService] stopped"); + } + } + }, "cn1-sim-foreground-service").start(); + } + return text; + } + + @Override + public void updateForegroundServiceNotification(Object nativeHandle, String title, String body) { + if (nativeHandle instanceof String[]) { + ((String[]) nativeHandle)[0] = title; + ((String[]) nativeHandle)[1] = body; + } + System.out.println("[ForegroundService] update: " + title + " - " + body); + SimulatorNotifications.setForegroundServiceStatus(title + " - " + body); + } + + @Override + public void stopForegroundService(Object nativeHandle) { + System.out.println("[ForegroundService] stop requested"); + SimulatorNotifications.setForegroundServiceStatus(null); + } + + @Override + public void subscribeToPushTopic(String topic) { + System.out.println("[Push] subscribeToTopic: " + topic); + } + + @Override + public void unsubscribeFromPushTopic(String topic) { + System.out.println("[Push] unsubscribeFromTopic: " + topic); + } @@ -5591,6 +5876,8 @@ public void actionPerformed(ActionEvent e) { installLargerTextMenu(simulateMenu, pref, frm); + installNotificationBackgroundSimulationMenu(simulateMenu); + pause = new JMenuItem("Pause App"); simulateMenu.addSeparator(); simulateMenu.add(pause); @@ -6669,6 +6956,139 @@ public void actionPerformed(ActionEvent e) { * Preferences keys all start with "NfcSim." so they survive simulator * restarts. */ + private void installNotificationBackgroundSimulationMenu(JMenu simulateMenu) { + JMenu menu = new JMenu("Notifications and Background"); + + final JCheckBoxMenuItem network = new JCheckBoxMenuItem("Network available", simNetworkAvailable); + network.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + simNetworkAvailable = network.isSelected(); + } + }); + final JCheckBoxMenuItem charging = new JCheckBoxMenuItem("Charging", simCharging); + charging.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + simCharging = charging.isSelected(); + } + }); + final JCheckBoxMenuItem idle = new JCheckBoxMenuItem("Device idle", simDeviceIdle); + idle.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + simDeviceIdle = idle.isSelected(); + } + }); + final JCheckBoxMenuItem battery = new JCheckBoxMenuItem("Battery not low", simBatteryNotLow); + battery.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + simBatteryNotLow = battery.isSelected(); + } + }); + JMenu constraints = new JMenu("Background constraints"); + constraints.add(network); + constraints.add(charging); + constraints.add(idle); + constraints.add(battery); + menu.add(constraints); + + JMenuItem runWork = new JMenuItem("Run scheduled background work now"); + runWork.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + java.util.Collection all = new java.util.ArrayList(getScheduledWork().values()); + if (all.isEmpty()) { + JOptionPane.showMessageDialog(null, "No background work is currently scheduled."); + return; + } + for (WorkRequest r : all) { + if (constraintsSatisfied(r)) { + runWorkerNow(r); + } else { + System.out.println("[BackgroundWork] '" + r.getId() + "' not run: constraints not satisfied"); + } + } + } + }); + menu.add(runWork); + + JMenuItem channels = new JMenuItem("Show registered channels..."); + channels.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + StringBuilder sb = new StringBuilder(); + for (NotificationChannelBuilder c : getSimulatorChannels()) { + sb.append(c.getName()).append(" (").append(c.getId()).append(") importance=") + .append(c.getImportance()); + if (c.getSound() != null) { + sb.append(" sound=").append(c.getSound()); + } + sb.append('\n'); + } + JOptionPane.showMessageDialog(null, sb.length() == 0 ? "No channels registered." : sb.toString()); + } + }); + menu.add(channels); + + menu.addSeparator(); + + JMenuItem shareText = new JMenuItem("Send shared text..."); + shareText.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + String text = JOptionPane.showInputDialog(null, "Text to share into the app:", "Shared text", JOptionPane.PLAIN_MESSAGE); + if (text != null) { + deliverSharedContent(SharedContent.builder().addText(text).build()); + } + } + }); + menu.add(shareText); + + JMenuItem shareUrl = new JMenuItem("Send shared URL..."); + shareUrl.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + String url = JOptionPane.showInputDialog(null, "URL to share into the app:", "Shared URL", JOptionPane.PLAIN_MESSAGE); + if (url != null) { + deliverSharedContent(SharedContent.builder().addUrl(url).build()); + } + } + }); + menu.add(shareUrl); + + JMenuItem shareFile = new JMenuItem("Send shared file..."); + shareFile.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + JFileChooser fc = new JFileChooser(); + if (fc.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { + File f = fc.getSelectedFile(); + String path = f.getAbsolutePath(); + String lower = path.toLowerCase(); + boolean image = lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") + || lower.endsWith(".gif") || lower.endsWith(".webp"); + SharedContent.Builder b = SharedContent.builder(); + if (image) { + b.addImage(null, "file://" + path, f.getName()); + } else { + b.addFile(null, "file://" + path, f.getName()); + } + deliverSharedContent(b.build()); + } + } + }); + menu.add(shareFile); + + menu.addSeparator(); + JLabel fgStatus = new JLabel("Foreground service: stopped"); + fgStatus.setBorder(BorderFactory.createEmptyBorder(2, 8, 2, 8)); + SimulatorNotifications.setForegroundServiceLabel(fgStatus); + menu.add(fgStatus); + + simulateMenu.add(menu); + } + + private void deliverSharedContent(final SharedContent content) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + fireSharedContentReceived(content); + } + }); + } + private void installNfcSimulationMenu(JMenu simulateMenu, final Preferences pref) { JMenu nfcMenu = new JMenu("NFC"); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/SimulatorNotifications.java b/Ports/JavaSE/src/com/codename1/impl/javase/SimulatorNotifications.java new file mode 100644 index 0000000000..b7e49ed2fe --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/SimulatorNotifications.java @@ -0,0 +1,315 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import com.codename1.notifications.LocalNotification; +import com.codename1.notifications.NotificationChannelBuilder; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.GraphicsEnvironment; +import java.awt.Image; +import java.awt.Rectangle; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.io.File; +import java.util.LinkedHashMap; +import java.util.Map; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JProgressBar; +import javax.swing.JTextField; +import javax.swing.JWindow; +import javax.swing.SwingUtilities; + +/// Renders local notifications in the JavaSE simulator using a stack of small Swing +/// windows shown over the top-right of the simulator. Each window shows the channel name, +/// title, body, optional image and progress bar, and a button per action (with an inline +/// text field for quick-reply actions). Tapping the body or an action routes back into the +/// running app through `JavaSEPort#dispatchLocalNotification`. +class SimulatorNotifications { + + private static final Map ACTIVE = new LinkedHashMap(); + private static JLabel foregroundServiceLabel; + + private SimulatorNotifications() { + } + + static void show(final JavaSEPort port, final LocalNotification notif) { + if (GraphicsEnvironment.isHeadless()) { + return; + } + if (port.isMinimized()) { + // when minimized the simulator window is hidden; fall back to the system tray + port.showTrayNotification(notif); + return; + } + SwingUtilities.invokeLater(new Runnable() { + public void run() { + buildAndShow(port, notif); + } + }); + } + + private static void buildAndShow(final JavaSEPort port, final LocalNotification notif) { + dismissInternal(notif.getId()); + + final JWindow w = new JWindow(); + JPanel root = new JPanel(new BorderLayout(8, 4)); + root.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(new Color(60, 60, 60)), + BorderFactory.createEmptyBorder(8, 10, 8, 10))); + root.setBackground(new Color(250, 250, 250)); + + // channel line + NotificationChannelBuilder ch = port.getSimulatorChannel(notif.getChannelId()); + String chName = ch != null ? ch.getName() : notif.getChannelId(); + if (chName != null) { + JLabel chLabel = new JLabel(chName); + chLabel.setForeground(new Color(120, 120, 120)); + chLabel.setFont(chLabel.getFont().deriveFont(Font.PLAIN, 10f)); + root.add(chLabel, BorderLayout.NORTH); + } + + // image thumbnail on the left + String img = notif.getAlertImage(); + if (img != null && img.length() > 0) { + try { + File f = resolveImage(img); + if (f != null && f.exists()) { + Image scaled = new ImageIcon(f.getAbsolutePath()).getImage() + .getScaledInstance(48, 48, Image.SCALE_SMOOTH); + root.add(new JLabel(new ImageIcon(scaled)), BorderLayout.WEST); + } + } catch (Exception ignore) { + } + } + + JPanel center = new JPanel(); + center.setOpaque(false); + center.setLayout(new BoxLayout(center, BoxLayout.Y_AXIS)); + + JLabel title = new JLabel(notif.getAlertTitle() == null ? "" : notif.getAlertTitle()); + title.setFont(title.getFont().deriveFont(Font.BOLD, 13f)); + center.add(title); + + String bodyText = notif.getAlertBody(); + if (notif.getMessagingStyle() != null && !notif.getMessagingStyle().getMessages().isEmpty()) { + StringBuilder sb = new StringBuilder(""); + for (LocalNotification.MessagingStyle.Message m : notif.getMessagingStyle().getMessages()) { + String sender = m.getSenderName() == null ? notif.getMessagingStyle().getSelfDisplayName() : m.getSenderName(); + sb.append("").append(escape(sender)).append(": ").append(escape(m.getText())).append("
"); + } + sb.append(""); + bodyText = sb.toString(); + } + if (bodyText != null) { + JLabel body = new JLabel(bodyText); + body.setFont(body.getFont().deriveFont(Font.PLAIN, 12f)); + center.add(body); + } + + if (notif.getProgressMax() > 0 || notif.isProgressIndeterminate()) { + JProgressBar bar = new JProgressBar(0, Math.max(1, notif.getProgressMax())); + if (notif.isProgressIndeterminate()) { + bar.setIndeterminate(true); + } else { + bar.setValue(notif.getProgress()); + } + bar.setPreferredSize(new Dimension(220, 12)); + center.add(Box.createVerticalStrut(4)); + center.add(bar); + } + + // action buttons + if (!notif.getActions().isEmpty()) { + JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2)); + actions.setOpaque(false); + for (final LocalNotification.Action a : notif.getActions()) { + final JButton b = new JButton(a.getTitle()); + b.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + if (a.isTextInput()) { + promptForReply(port, notif.getId(), a, w); + } else { + port.dispatchLocalNotification(notif.getId(), a.getId(), a.getTitle(), null); + dismissInternal(notif.getId()); + } + } + }); + actions.add(b); + } + center.add(Box.createVerticalStrut(4)); + center.add(actions); + } + + root.add(center, BorderLayout.CENTER); + + // tapping the body launches the notification + JButton tap = new JButton("Open"); + tap.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + port.dispatchLocalNotification(notif.getId(), null, null, null); + dismissInternal(notif.getId()); + } + }); + JButton close = new JButton("x"); + close.setMargin(new java.awt.Insets(0, 4, 0, 4)); + close.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + dismissInternal(notif.getId()); + } + }); + JPanel east = new JPanel(); + east.setOpaque(false); + east.setLayout(new BoxLayout(east, BoxLayout.Y_AXIS)); + east.add(close); + east.add(Box.createVerticalGlue()); + east.add(tap); + root.add(east, BorderLayout.EAST); + + w.setContentPane(root); + w.pack(); + if (w.getWidth() > 360) { + w.setSize(360, w.getHeight()); + } + w.setAlwaysOnTop(true); + positionWindow(w); + w.setVisible(true); + ACTIVE.put(notif.getId(), w); + } + + private static void promptForReply(final JavaSEPort port, final String id, final LocalNotification.Action a, JWindow parent) { + final JWindow reply = new JWindow(); + JPanel p = new JPanel(new BorderLayout(4, 4)); + p.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(new Color(60, 60, 60)), + BorderFactory.createEmptyBorder(6, 8, 6, 8))); + final JTextField field = new JTextField(20); + if (a.getTextInputPlaceholder() != null) { + field.setToolTipText(a.getTextInputPlaceholder()); + } + JButton send = new JButton(a.getTextInputButtonText() == null ? "Send" : a.getTextInputButtonText()); + ActionListener submit = new ActionListener() { + public void actionPerformed(ActionEvent e) { + port.dispatchLocalNotification(id, a.getId(), a.getTitle(), field.getText()); + reply.dispose(); + dismissInternal(id); + } + }; + send.addActionListener(submit); + field.addActionListener(submit); + p.add(new JLabel(a.getTitle()), BorderLayout.NORTH); + p.add(field, BorderLayout.CENTER); + p.add(send, BorderLayout.EAST); + reply.setContentPane(p); + reply.pack(); + reply.setLocationRelativeTo(parent); + reply.setAlwaysOnTop(true); + reply.setVisible(true); + field.requestFocusInWindow(); + } + + static void dismiss(final String id) { + SwingUtilities.invokeLater(new Runnable() { + public void run() { + dismissInternal(id); + } + }); + } + + private static void dismissInternal(String id) { + JWindow w = ACTIVE.remove(id); + if (w != null) { + w.dispose(); + } + relayout(); + } + + private static void positionWindow(JWindow w) { + relayout(); + if (!w.isVisible()) { + Rectangle screen = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds(); + int y = screen.y + 20 + ACTIVE.size() * (w.getHeight() + 8); + w.setLocation(screen.x + screen.width - w.getWidth() - 20, y); + } + } + + private static void relayout() { + Rectangle screen = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds(); + int y = screen.y + 20; + for (JWindow w : ACTIVE.values()) { + w.setLocation(screen.x + screen.width - w.getWidth() - 20, y); + y += w.getHeight() + 8; + } + } + + private static File resolveImage(String path) { + File f = new File(path); + if (f.exists()) { + return f; + } + // try relative to the src/native roots commonly used in projects + String[] roots = {"src/main/resources", "src", "native/javase", "."}; + for (String r : roots) { + File candidate = new File(r, path); + if (candidate.exists()) { + return candidate; + } + } + return null; + } + + private static String escape(String s) { + if (s == null) { + return ""; + } + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + // ---- foreground service status indicator (wired to the Simulate menu) ---- + + static void setForegroundServiceLabel(JLabel label) { + foregroundServiceLabel = label; + } + + static void setForegroundServiceStatus(final String status) { + final JLabel l = foregroundServiceLabel; + if (l == null) { + return; + } + SwingUtilities.invokeLater(new Runnable() { + public void run() { + l.setText(status == null ? "Foreground service: stopped" : "Foreground service: " + status); + } + }); + } +} diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index 6ec9193de2..e9bb63bef3 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -31,6 +31,7 @@ #import "CodenameOne_GLViewController.h" #import "CN1TapGestureRecognizer.h" #include "com_codename1_impl_ios_IOSImplementation.h" +#include "com_codename1_impl_ios_IOSNative.h" #include "com_codename1_push_PushContent.h" #include "com_codename1_ui_Display.h" #ifdef NEW_CODENAME_ONE_VM @@ -445,7 +446,20 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( #endif //afterDidFinishLaunchingWithOptionsMarkerEntry - + + // Register BGTaskScheduler processing identifiers declared in the Info.plist + // BGTaskSchedulerPermittedIdentifiers array. This must run before this method returns. + if (@available(iOS 13.0, *)) { + NSArray *permitted = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"BGTaskSchedulerPermittedIdentifiers"]; + if ([permitted isKindOfClass:[NSArray class]]) { + for (id idObj in permitted) { + if ([idObj isKindOfClass:[NSString class]]) { + com_codename1_impl_ios_IOSNative_registerBackgroundProcessingTask___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG JAVA_NULL, fromNSString(CN1_THREAD_GET_STATE_PASS_ARG (NSString*)idObj)); + } + } + } + } + #ifdef INCLUDE_FACEBOOK_CONNECT return [[FBSDKApplicationDelegate sharedInstance] application:application didFinishLaunchingWithOptions:launchOptions]; @@ -541,6 +555,16 @@ Restart any tasks that were paused (or not yet started) while the application wa */ //[self.viewController startAnimation]; [self cn1ApplicationDidBecomeActive]; + + // Deliver any content shared into the app via the share extension. The shared App + // Group name is written into the Info.plist by the build (CN1ShareAppGroup). + NSString *shareGroup = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CN1ShareAppGroup"]; + if ([shareGroup isKindOfClass:[NSString class]] && [shareGroup length] > 0) { + JAVA_OBJECT json = com_codename1_impl_ios_IOSNative_getPendingSharedContent___java_lang_String_R_java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG JAVA_NULL, fromNSString(CN1_THREAD_GET_STATE_PASS_ARG shareGroup)); + if (json != JAVA_NULL) { + com_codename1_impl_ios_IOSImplementation_fireSharedContentFromNative___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG json); + } + } } - (void)applicationWillTerminate:(UIApplication *)application diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index b1ad753018..959edb2dda 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -140,6 +140,7 @@ #ifdef CN1_INCLUDE_NOTIFICATIONS2 #import #endif +#import #ifdef INCLUDE_PHOTOLIBRARY_USAGE #ifdef ENABLE_GALLERY_MULTISELECT #ifdef USE_PHOTOKIT_FOR_MULTIGALLERY @@ -11129,6 +11130,244 @@ JAVA_VOID com_codename1_impl_ios_IOSNative_cancelLocalNotification___java_lang_S }); } +// --------------------------------------------------------------------------- +// Enriched local notifications, permission, BGTaskScheduler and shared content +// --------------------------------------------------------------------------- + +#ifdef CN1_INCLUDE_NOTIFICATIONS2 +// Builds and registers a UNNotificationCategory from the packed actions string. Field +// separator is U+0001 and record separator is U+0002 (see IOSImplementation). +static NSString* cn1RegisterLocalNotificationCategory(NSString *categoryId, NSString *actionsEncoded) API_AVAILABLE(ios(10.0)) { + if (categoryId == nil || actionsEncoded == nil || [actionsEncoded length] == 0) { + return nil; + } + NSString *recSep = [NSString stringWithFormat:@"%C", (unichar)2]; + NSString *fldSep = [NSString stringWithFormat:@"%C", (unichar)1]; + NSMutableArray *actions = [[NSMutableArray alloc] init]; + for (NSString *rec in [actionsEncoded componentsSeparatedByString:recSep]) { + NSArray *parts = [rec componentsSeparatedByString:fldSep]; + if ([parts count] < 2) { + continue; + } + NSString *aid = parts[0]; + NSString *title = parts[1]; + NSString *placeholder = [parts count] > 2 ? parts[2] : @""; + NSString *button = [parts count] > 3 ? parts[3] : @""; + if ([placeholder length] > 0 || [button length] > 0) { + if ([button length] == 0) { button = @"Reply"; } + [actions addObject:[UNTextInputNotificationAction actionWithIdentifier:aid title:title options:UNNotificationActionOptionNone textInputButtonTitle:button textInputPlaceholder:placeholder]]; + } else { + [actions addObject:[UNNotificationAction actionWithIdentifier:aid title:title options:UNNotificationActionOptionForeground]]; + } + } + UNNotificationCategory *category = [UNNotificationCategory categoryWithIdentifier:categoryId actions:actions intentIdentifiers:@[] options:UNNotificationCategoryOptionNone]; + UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; + [center getNotificationCategoriesWithCompletionHandler:^(NSSet * _Nonnull existing) { + NSMutableSet *merged = existing == nil ? [[NSMutableSet alloc] init] : [existing mutableCopy]; + [merged addObject:category]; + [center setNotificationCategories:merged]; + }]; + return categoryId; +} +#endif + +JAVA_VOID com_codename1_impl_ios_IOSNative_sendLocalNotification2___java_lang_String_java_lang_String_java_lang_String_java_lang_String_int_long_int_boolean_java_lang_String_java_lang_String_boolean_java_lang_String_java_lang_String( CN1_THREAD_STATE_MULTI_ARG + JAVA_OBJECT me, JAVA_OBJECT notificationId, JAVA_OBJECT alertTitle, JAVA_OBJECT alertBody, JAVA_OBJECT alertSound, JAVA_INT badgeNumber, JAVA_LONG fireDate, JAVA_INT repeatType, JAVA_BOOLEAN foreground, + JAVA_OBJECT categoryId, JAVA_OBJECT threadId, JAVA_BOOLEAN timeSensitive, JAVA_OBJECT imageAttachmentPath, JAVA_OBJECT actionsEncoded) { +#ifdef CN1_INCLUDE_NOTIFICATIONS2 + if (@available(iOS 10, *)) { + NSString *title = alertTitle == NULL ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG alertTitle); + NSString *body = alertBody == NULL ? @"" : toNSString(CN1_THREAD_STATE_PASS_ARG alertBody); + body = [body stringByReplacingOccurrencesOfString:@"%" withString:@"%%"]; + + NSString *notificationIdString = toNSString(CN1_THREAD_STATE_PASS_ARG notificationId); + NSMutableDictionary *dict = [[NSMutableDictionary alloc] init]; + [dict setObject:notificationIdString forKey:@"__ios_id__"]; + if (foreground) { + [dict setObject:@"true" forKey:@"foreground"]; + } + + UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init]; + content.title = [NSString localizedUserNotificationStringForKey:title arguments:nil]; + content.body = [NSString localizedUserNotificationStringForKey:body arguments:nil]; + if (alertSound != NULL) { + NSString *soundName = toNSString(CN1_THREAD_STATE_PASS_ARG alertSound); + if (soundName != nil && [soundName length] > 0) { + content.sound = [UNNotificationSound soundNamed:soundName]; + } + } + if (badgeNumber >= 0) { + content.badge = [NSNumber numberWithInt:badgeNumber]; + } + content.userInfo = dict; + if (threadId != NULL) { + NSString *t = toNSString(CN1_THREAD_STATE_PASS_ARG threadId); + if ([t length] > 0) { + content.threadIdentifier = t; + } + } + if (timeSensitive) { + if (@available(iOS 15.0, *)) { + content.interruptionLevel = UNNotificationInterruptionLevelTimeSensitive; + } + } + NSString *cat = categoryId == NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG categoryId); + NSString *acts = actionsEncoded == NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG actionsEncoded); + NSString *registered = cn1RegisterLocalNotificationCategory(cat, acts); + if (registered != nil) { + content.categoryIdentifier = registered; + } + if (imageAttachmentPath != NULL) { + NSString *imgPath = toNSString(CN1_THREAD_STATE_PASS_ARG imageAttachmentPath); + if (imgPath != nil && [imgPath length] > 0) { + NSURL *url = nil; + if ([imgPath hasPrefix:@"file://"]) { + url = [NSURL URLWithString:imgPath]; + } else { + NSString *clean = [imgPath hasPrefix:@"/"] ? [imgPath substringFromIndex:1] : imgPath; + NSString *resPath = [[NSBundle mainBundle] pathForResource:[clean stringByDeletingPathExtension] ofType:[clean pathExtension]]; + if (resPath != nil) { + url = [NSURL fileURLWithPath:resPath]; + } + } + if (url != nil) { + NSError *attErr = nil; + UNNotificationAttachment *att = [UNNotificationAttachment attachmentWithIdentifier:@"image" URL:url options:nil error:&attErr]; + if (att != nil) { + content.attachments = @[att]; + } + } + } + } + + UNNotificationTrigger *trigger = cn1CreateNotificationTrigger(fireDate, repeatType); + UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:notificationIdString content:content trigger:trigger]; + UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; + [center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert + UNAuthorizationOptionSound + UNAuthorizationOptionBadge) + completionHandler:^(BOOL granted, NSError * _Nullable authError) { + if (authError != nil) { + CN1Log(@"Local notification authorization request failed: %@", authError.localizedDescription); + } + }]; + cn1CancelScheduledLocalNotificationById(notificationIdString); + [center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) { + if (error != nil) { + CN1Log(@"Failed to schedule local notification: %@", error.localizedDescription); + } + }]; + } else { + CN1Log(@"Ignoring local notification request on iOS versions below 10"); + } +#endif +} + +JAVA_VOID com_codename1_impl_ios_IOSNative_requestNotificationPermission___int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT optionsMask) { +#ifdef CN1_INCLUDE_NOTIFICATIONS2 + if (@available(iOS 10, *)) { + UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; + UNAuthorizationOptions opts = (UNAuthorizationOptions)optionsMask; + [center requestAuthorizationWithOptions:opts completionHandler:^(BOOL granted, NSError * _Nullable error) { + [center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) { + int level = 1; // denied + switch (settings.authorizationStatus) { + case UNAuthorizationStatusNotDetermined: level = 0; break; + case UNAuthorizationStatusDenied: level = 1; break; + case UNAuthorizationStatusAuthorized: level = 2; break; + default: break; + } + if (@available(iOS 12.0, *)) { + if (settings.authorizationStatus == UNAuthorizationStatusProvisional) { level = 3; } + } + if (@available(iOS 14.0, *)) { + if (settings.authorizationStatus == UNAuthorizationStatusEphemeral) { level = 4; } + } + BOOL g = (level == 2 || level == 3 || level == 4); + com_codename1_impl_ios_IOSImplementation_notificationPermissionResult___boolean_int(CN1_THREAD_GET_STATE_PASS_ARG g ? JAVA_TRUE : JAVA_FALSE, level); + }]; + }]; + return; + } +#endif + com_codename1_impl_ios_IOSImplementation_notificationPermissionResult___boolean_int(CN1_THREAD_GET_STATE_PASS_ARG JAVA_TRUE, 2); +} + +JAVA_VOID com_codename1_impl_ios_IOSNative_registerBackgroundProcessingTask___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT identifier) { + if (@available(iOS 13.0, *)) { + NSString *taskId = toNSString(CN1_THREAD_STATE_PASS_ARG identifier); + [[BGTaskScheduler sharedScheduler] registerForTaskWithIdentifier:taskId usingQueue:nil launchHandler:^(BGTask * _Nonnull task) { + task.expirationHandler = ^{ + [task setTaskCompletedWithSuccess:NO]; + }; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + com_codename1_impl_ios_IOSImplementation_runBackgroundProcessing___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG fromNSString(CN1_THREAD_GET_STATE_PASS_ARG taskId)); + [task setTaskCompletedWithSuccess:YES]; + }); + }]; + } +} + +JAVA_VOID com_codename1_impl_ios_IOSNative_submitBackgroundProcessingTask___java_lang_String_double_boolean_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT identifier, JAVA_DOUBLE earliest, JAVA_BOOLEAN requiresNetwork, JAVA_BOOLEAN requiresPower) { + if (@available(iOS 13.0, *)) { + NSString *taskId = toNSString(CN1_THREAD_STATE_PASS_ARG identifier); + BGProcessingTaskRequest *request = [[BGProcessingTaskRequest alloc] initWithIdentifier:taskId]; + request.requiresNetworkConnectivity = requiresNetwork ? YES : NO; + request.requiresExternalPower = requiresPower ? YES : NO; + if (earliest > 0) { + request.earliestBeginDate = [NSDate dateWithTimeIntervalSince1970:earliest]; + } + NSError *submitError = nil; + [[BGTaskScheduler sharedScheduler] submitTaskRequest:request error:&submitError]; + if (submitError != nil) { + CN1Log(@"Failed to submit background processing task %@: %@", taskId, submitError.localizedDescription); + } + } +} + +JAVA_VOID com_codename1_impl_ios_IOSNative_cancelBackgroundTask___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT identifier) { + if (@available(iOS 13.0, *)) { + NSString *taskId = toNSString(CN1_THREAD_STATE_PASS_ARG identifier); + [[BGTaskScheduler sharedScheduler] cancelTaskRequestWithIdentifier:taskId]; + } +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isBackgroundProcessingSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + if (@available(iOS 13.0, *)) { + return JAVA_TRUE; + } + return JAVA_FALSE; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isBackgroundProcessingSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return com_codename1_impl_ios_IOSNative_isBackgroundProcessingSupported___R_boolean(CN1_THREAD_STATE_PASS_ARG me); +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_getPendingSharedContent___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT appGroupId) { + if (appGroupId == JAVA_NULL) { + return JAVA_NULL; + } + NSString *group = toNSString(CN1_THREAD_STATE_PASS_ARG appGroupId); + if (group == nil || [group length] == 0) { + return JAVA_NULL; + } + NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:group]; + id payload = [defaults objectForKey:@"cn1.shareExtension.payload"]; + if (payload == nil) { + return JAVA_NULL; + } + [defaults removeObjectForKey:@"cn1.shareExtension.payload"]; + [defaults synchronize]; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; + if (jsonData == nil) { + return JAVA_NULL; + } + NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + return fromNSString(CN1_THREAD_STATE_PASS_ARG jsonString); +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_getPendingSharedContent___java_lang_String_R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT appGroupId) { + return com_codename1_impl_ios_IOSNative_getPendingSharedContent___java_lang_String(CN1_THREAD_STATE_PASS_ARG me, appGroupId); +} + // BEGIN IOSImplementation native code, this is used to optimize various "heavy" IOSImplementation methods #define DRAW_BGIMAGE_AT_GIVEN_POSITION_WITH_FILL_RECT(xpositionToDraw, ypositionToDraw) JAVA_BYTE bgTransparency = com_codename1_ui_plaf_Style_getBgTransparency___R_byte(threadStateData, s); \ diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 21f1ad683b..aecabf6b79 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -90,6 +90,13 @@ import com.codename1.media.MediaRecorderBuilder; import com.codename1.notifications.LocalNotification; import com.codename1.notifications.LocalNotificationCallback; +import com.codename1.notifications.NotificationPermissionCallback; +import com.codename1.notifications.NotificationPermissionRequest; +import com.codename1.notifications.NotificationPermissionResult; +import com.codename1.background.BackgroundWorker; +import com.codename1.background.ForegroundService; +import com.codename1.background.WorkRequest; +import com.codename1.share.SharedContent; import com.codename1.payment.RestoreCallback; import com.codename1.push.PushAction; import com.codename1.push.PushActionCategory; @@ -9327,6 +9334,7 @@ public void run() { public static void setMainClass(Object main) { + setCurrentApplicationInstance(main); if(main instanceof PushCallback) { pushCallback = (PushCallback)main; } @@ -10340,27 +10348,239 @@ public void splitString(String source, char separator, ArrayList out) { } public void scheduleLocalNotification(LocalNotification n, long firstTime, int repeat) { - - nativeInstance.sendLocalNotification( - n.getId(), - n.getAlertTitle(), - n.getAlertBody(), - n.getAlertSound(), - n.getBadgeNumber(), - firstTime, - repeat, - n.isForeground() - ); - - + boolean enriched = !n.getActions().isEmpty() || n.getGroupId() != null + || n.isTimeSensitive() || (n.getAlertImage() != null && n.getAlertImage().length() > 0); + if (enriched) { + String categoryId = null; + String actionsEncoded = null; + if (!n.getActions().isEmpty()) { + categoryId = "cn1-ln-" + n.getId(); + StringBuilder sb = new StringBuilder(); + for (LocalNotification.Action a : n.getActions()) { + if (sb.length() > 0) { + sb.append('\u0002'); + } + sb.append(nullToEmpty(a.getId())).append('\u0001') + .append(nullToEmpty(a.getTitle())).append('\u0001') + .append(nullToEmpty(a.getTextInputPlaceholder())).append('\u0001') + .append(nullToEmpty(a.getTextInputButtonText())); + } + actionsEncoded = sb.toString(); + } + nativeInstance.sendLocalNotification2( + n.getId(), n.getAlertTitle(), n.getAlertBody(), n.getAlertSound(), + n.getBadgeNumber(), firstTime, repeat, n.isForeground(), + categoryId, n.getGroupId(), n.isTimeSensitive(), n.getAlertImage(), actionsEncoded); + } else { + nativeInstance.sendLocalNotification( + n.getId(), + n.getAlertTitle(), + n.getAlertBody(), + n.getAlertSound(), + n.getBadgeNumber(), + firstTime, + repeat, + n.isForeground() + ); + } + } + + private static String nullToEmpty(String s) { + return s == null ? "" : s; } - - public void cancelLocalNotification(String id) { nativeInstance.cancelLocalNotification(id); } + // ---- notification permission ---- + + private static NotificationPermissionCallback pendingNotificationPermissionCallback; + + @Override + public void requestNotificationPermission(NotificationPermissionRequest request, NotificationPermissionCallback callback) { + pendingNotificationPermissionCallback = callback; + nativeInstance.requestNotificationPermission(request == null ? 7 : request.toAuthorizationOptionsMask()); + } + + /// Invoked from native once the authorization request resolves. authLevel is the + /// ordinal of NotificationPermissionResult.AuthorizationLevel as produced by the + /// native UNAuthorizationStatus mapping (granted is derived from the level). + public static void notificationPermissionResult(final boolean granted, final int authLevel) { + final NotificationPermissionCallback cb = pendingNotificationPermissionCallback; + pendingNotificationPermissionCallback = null; + if (cb != null) { + final NotificationPermissionResult.AuthorizationLevel[] levels = + NotificationPermissionResult.AuthorizationLevel.values(); + final NotificationPermissionResult.AuthorizationLevel level = + (authLevel >= 0 && authLevel < levels.length) + ? levels[authLevel] + : NotificationPermissionResult.AuthorizationLevel.NOT_DETERMINED; + Display.getInstance().callSerially(new Runnable() { + public void run() { + cb.notificationPermissionResult(new NotificationPermissionResult(level)); + } + }); + } + } + + // ---- constraint-aware background work / processing (BGTaskScheduler) ---- + + @Override + public boolean isBackgroundWorkSupported() { + return nativeInstance.isBackgroundProcessingSupported(); + } + + @Override + public boolean isBackgroundProcessingSupported() { + return nativeInstance.isBackgroundProcessingSupported(); + } + + @Override + public void scheduleBackgroundWork(WorkRequest request) { + // persist worker class and input so the work can be reconstructed after a cold launch + com.codename1.io.Preferences.set("$$CN1_BGWORK_CLASS_" + request.getId(), request.getWorkerClass()); + StringBuilder input = new StringBuilder(); + for (java.util.Map.Entry e : request.getInputData().entrySet()) { + if (input.length() > 0) { + input.append('\u0002'); + } + input.append(e.getKey()).append('\u0001').append(e.getValue()); + } + com.codename1.io.Preferences.set("$$CN1_BGWORK_INPUT_" + request.getId(), input.toString()); + com.codename1.io.Preferences.set("$$CN1_BGWORK_PERIODIC_" + request.getId(), request.isPeriodic()); + double earliest = (System.currentTimeMillis() + Math.max(0, request.getInitialDelayMillis())) / 1000.0; + nativeInstance.submitBackgroundProcessingTask(request.getId(), earliest, + request.isRequiresNetwork() || request.isRequiresUnmeteredNetwork(), request.isRequiresCharging()); + } + + @Override + public void cancelBackgroundWork(String workId) { + nativeInstance.cancelBackgroundTask(workId); + } + + @Override + public void scheduleBackgroundProcessing(String id, long earliestBeginEpochMs, boolean requiresNetwork, boolean requiresPower, Runnable task) { + if (task != null) { + backgroundProcessingRunnables.put(id, task); + } + double earliest = earliestBeginEpochMs <= 0 ? System.currentTimeMillis() / 1000.0 : earliestBeginEpochMs / 1000.0; + nativeInstance.submitBackgroundProcessingTask(id, earliest, requiresNetwork, requiresPower); + } + + @Override + public void cancelBackgroundProcessing(String id) { + backgroundProcessingRunnables.remove(id); + nativeInstance.cancelBackgroundTask(id); + } + + private static final java.util.Map backgroundProcessingRunnables = new java.util.HashMap(); + + /// Invoked from the BGTaskScheduler launch handler. Runs the worker (reconstructed from + /// persisted state) or a live processing runnable for the given identifier. + public static void runBackgroundProcessing(final String id) { + Runnable live = backgroundProcessingRunnables.remove(id); + if (live != null) { + try { + live.run(); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + return; + } + String workerClass = com.codename1.io.Preferences.get("$$CN1_BGWORK_CLASS_" + id, null); + if (workerClass == null) { + return; + } + try { + Class cls = Class.forName(workerClass); + BackgroundWorker worker = (BackgroundWorker) cls.newInstance(); + java.util.Map input = new java.util.HashMap(); + String enc = com.codename1.io.Preferences.get("$$CN1_BGWORK_INPUT_" + id, ""); + if (enc != null && enc.length() > 0) { + for (String pair : com.codename1.io.Util.split(enc, "\u0002")) { + int idx = pair.indexOf('\u0001'); + if (idx >= 0) { + input.put(pair.substring(0, idx), pair.substring(idx + 1)); + } + } + } + final boolean periodic = com.codename1.io.Preferences.get("$$CN1_BGWORK_PERIODIC_" + id, false); + worker.performWork(id, input, System.currentTimeMillis() + 25000, new com.codename1.util.Callback() { + public void onSucess(Boolean value) { + if (periodic) { + // resubmit to approximate periodic behavior on iOS + instance.nativeInstance.submitBackgroundProcessingTask(id, (System.currentTimeMillis() + 60000) / 1000.0, false, false); + } + } + public void onError(Object sender, Throwable err, int errorCode, String errorMessage) { + com.codename1.io.Log.e(err); + } + }); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void subscribeToPushTopic(String topic) { + com.codename1.io.Log.p("Push topics are not supported on iOS APNs; topic '" + topic + + "' must be handled server side"); + } + + @Override + public void unsubscribeFromPushTopic(String topic) { + com.codename1.io.Log.p("Push topics are not supported on iOS APNs; topic '" + topic + + "' must be handled server side"); + } + + @Override + public boolean isReceiveSharedContentSupported() { + return true; + } + + /// Invoked from native (on app activation) with the JSON payload written by the share + /// extension. Parses it into a SharedContent and dispatches to the app. + public static void fireSharedContentFromNative(String json) { + if (json == null || json.length() == 0) { + return; + } + try { + com.codename1.io.JSONParser parser = new com.codename1.io.JSONParser(); + java.util.Map parsed = parser.parseJSON(new java.io.StringReader(json)); + SharedContent.Builder b = SharedContent.builder(); + Object subject = parsed.get("subject"); + if (subject instanceof String) { + b.subject((String) subject); + } + Object items = parsed.get("items"); + if (items instanceof java.util.List) { + for (Object o : (java.util.List) items) { + if (!(o instanceof java.util.Map)) { + continue; + } + java.util.Map item = (java.util.Map) o; + String kind = (String) item.get("kind"); + String value = (String) item.get("value"); + if ("url".equals(kind)) { + b.addUrl(value); + } else if ("image".equals(kind)) { + b.addImage(null, value, null); + } else if ("file".equals(kind)) { + b.addFile(null, value, null); + } else { + b.addText(value); + } + } + } + if (instance != null) { + instance.fireSharedContentReceived(b.build()); + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + static class ClipShape implements Shape { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index cda442de91..332d53d37f 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -730,8 +730,34 @@ native void nativeSetTransformMutable( native void sendLocalNotification(String id, String alertTitle, String alertBody, String alertSound, int badgeNumber, long fireDate, int repeatType, boolean foreground); + /// Enriched local notification scheduling carrying actions, grouping, time-sensitive + /// flag and an image attachment. actionsEncoded packs the actions as + /// idtitleplaceholderbutton records separated by . + native void sendLocalNotification2(String id, String alertTitle, String alertBody, String alertSound, int badgeNumber, long fireDate, int repeatType, boolean foreground, String categoryId, String threadId, boolean timeSensitive, String imageAttachmentPath, String actionsEncoded); + native void cancelLocalNotification(String id); + /// Requests notification authorization with the given UNAuthorizationOptions mask. The + /// result is delivered asynchronously to IOSImplementation.notificationPermissionResult. + native void requestNotificationPermission(int optionsMask); + + /// Registers a BGTaskScheduler processing task identifier. Must be called before + /// application:didFinishLaunchingWithOptions: returns. + native void registerBackgroundProcessingTask(String identifier); + + /// Submits a BGProcessingTaskRequest for the given identifier. + native void submitBackgroundProcessingTask(String identifier, double earliestBeginEpochSeconds, boolean requiresNetwork, boolean requiresPower); + + /// Cancels a pending BGTaskScheduler request by identifier. + native void cancelBackgroundTask(String identifier); + + /// True if BGTaskScheduler (iOS 13+) is available. + native boolean isBackgroundProcessingSupported(); + + /// Reads and clears any shared content payload written by the share extension into the + /// shared App Group user defaults. Returns a JSON string or null if there is none. + native String getPendingSharedContent(String appGroupId); + // --- Biometrics (LocalAuthentication.framework) ------------------------- /** True when LAContext.canEvaluatePolicy(deviceOwnerAuthenticationWithBiometrics) succeeds. */ diff --git a/docs/developer-guide/Notifications-And-Background-Execution.asciidoc b/docs/developer-guide/Notifications-And-Background-Execution.asciidoc new file mode 100644 index 0000000000..aab94509c0 --- /dev/null +++ b/docs/developer-guide/Notifications-And-Background-Execution.asciidoc @@ -0,0 +1,244 @@ +== Notifications and Background Execution + +Codename One exposes a cross-platform API for rich local notifications, runtime +notification permission, notification channels, constraint-aware background work, +foreground services, deferrable background processing, receiving content shared from +other apps, and push topic subscriptions. Each capability degrades on platforms that +can't support it -- calls become documented no-ops rather than errors -- and the JavaSE +simulator implements them so you can exercise the flows without a device. + +Because these features build on platform-specific behavior, every section below lists +the support per platform. In the matrices: *Yes* = fully supported, *Partial* = +supported with documented limitations, *No-op* = the call is accepted but does nothing, +*Sim* = exercised in the simulator. + +=== Notification permission + +On iOS, and on Android 13+ (`POST_NOTIFICATIONS`), an app must obtain permission before +its notifications are shown. Request it through `Display`: + +[source,java] +---- +import com.codename1.notifications.NotificationPermissionRequest; +import com.codename1.notifications.NotificationPermissionResult.AuthorizationLevel; + +NotificationPermissionRequest req = new NotificationPermissionRequest() + .provisional(true) // iOS: deliver quietly without an explicit prompt + .timeSensitive(true); // iOS: allow the time-sensitive interruption level + +Display.getInstance().requestNotificationPermission(req, result -> { + if (result.isGranted()) { + // schedule notifications + } + AuthorizationLevel level = result.getAuthorizationLevel(); +}); +---- + +`NotificationPermissionResult.getAuthorizationLevel()` returns an `AuthorizationLevel` +enum (`NOT_DETERMINED`, `DENIED`, `AUTHORIZED`, `PROVISIONAL`, `EPHEMERAL`). The values +are ordered from least to most permissive, so `isGranted()` is "more permissive than +`DENIED`." The callback is delivered on the EDT. + +[options="header"] +|=== +| Request option | iOS | Android | Simulator +| alert / sound / badge | Yes | Implied by POST_NOTIFICATIONS | Granted +| provisional | Yes (iOS 12+) | No-op | Granted +| timeSensitive | Yes (iOS 15+, needs entitlement) | No-op | Granted +| critical | Yes (needs Apple entitlement) | No-op | Granted +| carPlay / announcement | Yes | No-op | Granted +|=== + +The `timeSensitive` and `critical` options require an Apple capability on the App ID. +Enable injection of the matching entitlement with the `ios.timeSensitiveNotifications=true` +and `ios.criticalAlerts=true` build hints (they're opt-in because an entitlement the +provisioning profile doesn't carry breaks code signing). + +=== Local notifications + +`LocalNotification` is scheduled through `Display.scheduleLocalNotification(...)` as +before; the bean has been extended with fluent, backward-compatible setters: + +[source,java] +---- +LocalNotification n = new LocalNotification() + .setChannelId("messages") // Android channel (see below) + .setGroup("chat-42") // bundle related notifications + .setProgress(100, 40) // determinate progress bar (Android) + .setTimeSensitive(true); // break through Focus / elevated importance +n.setId("msg-42"); +n.setAlertTitle("New message"); +n.setAlertBody("Tap to reply"); +n.addAction("open", "Open"); +n.addInputAction("reply", "Reply", "Type a message", "Send"); // inline quick reply +Display.getInstance().scheduleLocalNotification(n, System.currentTimeMillis() + 1000, + LocalNotification.REPEAT_NONE); +---- + +When the user taps an action, the selected action id (and any quick-reply text) are +delivered through `com.codename1.push.PushContent` -- `getActionId()`, `getActionTitle()` +and `getTextResponse()` -- before your `LocalNotificationCallback.localNotificationReceived(id)` +runs, so push and local notification actions are handled identically. + +[options="header"] +|=== +| Capability | iOS | Android | Simulator +| title / body / badge / sound | Yes | Yes | Sim (panel) +| image attachment | Yes (UNNotificationAttachment) | Yes (BigPicture) | Sim (thumbnail) +| action buttons | Yes (UNNotificationAction) | Yes | Sim (buttons) +| quick-reply input action | Yes (UNTextInputNotificationAction) | Yes (RemoteInput) | Sim (text field) +| grouping | Yes (thread identifier) | Yes (group + summary) | Partial +| progress (ongoing) | No-op | Yes | Sim (progress bar) +| full-screen intent | No-op | Yes (needs permission, see hints) | No-op +| time-sensitive | Yes (iOS 15+) | Approximated (category + priority) | Sim +| messaging / conversation style | Partial | Yes (MessagingStyle) | Sim +| custom view | Notification content extension | RemoteViews | Sim (render) +|=== + +==== Resource names for sound, image and action icons + +These string parameters are platform *resource names*, not numeric ids (Codename One has +no `R.drawable` constants). They're resolved at runtime per platform: + +[options="header"] +|=== +| Parameter | What to provide | Android resolution | iOS resolution +| `alertSound` / channel sound | a file whose name starts with `notification_sound`, bundled in the app | `res/raw/` via `android.resource://` URI | `UNNotificationSound` from the app bundle +| `alertImage` | image path placed in the app root | loaded from `assets/` | `UNNotificationAttachment` from the bundle +| `Action` icon | a drawable name bundled in `native/android` | `getResources().getIdentifier(name, "drawable", pkg)` | not displayed +|=== + +The file extension is optional for the action icon and is ignored. iOS notification +action buttons don't render icons, so the icon value is ignored there. + +=== Notification channels (Android O+) + +Channels let the user control importance, sound, vibration, lights, lock screen +visibility and badge display for a group of notifications. Build and register one at startup, +then reference it from a notification with `setChannelId(...)`: + +[source,java] +---- +new NotificationChannelBuilder("messages", "Messages") + .description("Incoming chat messages") + .importance(NotificationChannelBuilder.IMPORTANCE_HIGH) + .sound("/notification_sound_ping.mp3") + .enableVibration(true) + .lightColor(0xff0000) + .register(); +---- + +Channels are an Android concept. On iOS and the simulator `register()` is a no-op (the +simulator stores the channel so its name can be shown in the notification panel); the +channel id you assign is still carried so the notification behaves consistently. + +=== Constraint-aware background work + +`BackgroundWork` schedules deferrable work that the OS runs when its constraints are met. +Implement `BackgroundWorker` in a class with a public no-argument constructor (it's +reconstructed after a cold launch -- pass state through the input data, not fields): + +[source,java] +---- +WorkRequest req = WorkRequest.builder("sync", SyncWorker.class) + .setRequiresNetwork(true) + .setRequiresCharging(true) + .setPeriodic(6 * 60 * 60 * 1000L) + .putInputData("account", "primary") + .build(); +BackgroundWork.schedule(req); +---- + +[options="header"] +|=== +| | iOS | Android | Simulator +| backend | BGTaskScheduler (iOS 13+) | JobScheduler | Timer +| requiresNetwork / unmetered | Yes | Yes | Toggle in Simulate menu +| requiresCharging | Yes (requiresExternalPower) | Yes | Toggle +| requiresIdle / batteryNotLow | No-op | Yes | Toggle +| periodic | Approximated (resubmit) | Yes (>= 15 min) | Yes +|=== + +`BackgroundTask.scheduleProcessing(id, earliestBeginDate, requiresNetwork, requiresPower, +runnable)` is a one-shot variant for occasional maintenance work. On iOS the identifiers +must be declared via the `ios.backgroundProcessingIds` build hint (comma separated, +default `.processing`); the build also adds the `processing` UIBackgroundMode +and links `BackgroundTasks.framework` automatically. + +=== Foreground service (Android) + +`ForegroundService` runs a long task while a persistent notification is shown: + +[source,java] +---- +ForegroundService svc = ForegroundService.start("downloads", "Downloading", "Please wait", + null, service -> { + for (int i = 0; i <= 100; i++) { + service.updateNotification("Downloading", i + "%"); + // ... do a chunk of work ... + } + }); +// auto-stops when the task returns, or call svc.stop() +---- + +[options="header"] +|=== +| | Android | iOS | Simulator +| persistent-notification service | Yes | No (best-effort background window + notification) | Sim (status indicator) +|=== + +When the app references `ForegroundService` the build registers the service and injects +`FOREGROUND_SERVICE` plus the per-type `FOREGROUND_SERVICE_` permission (Android 14). +Override the type with the `android.foregroundServiceType` build hint (default `dataSync`). + +=== Receiving shared content + +Override `onReceivedSharedContent(SharedContent)` on your `Lifecycle` subclass to receive +text, URLs, files or images shared into your app from other apps: + +[source,java] +---- +public class MyApp extends Lifecycle { + public void onReceivedSharedContent(SharedContent content) { + for (SharedContent.Item item : content.getItems()) { + if (item.getType() == SharedContent.TYPE_IMAGE) { + importImage(item.getFilePath()); // a FileSystemStorage path + } + } + } +} +---- + +[options="header"] +|=== +| | Android | iOS | Simulator +| receive shared content | Yes (SEND / SEND_MULTIPLE) | Yes (share extension + App Group) | Sim ("Send shared content") +|=== + +On Android, declare the accepted MIME types with the `android.shareFilter` build hint +(comma separated, for example `text/plain,image/*`); the build registers the share-receiver +activity. On iOS this reuses the share-extension authoring pipeline; set the +`ios.shareAppGroup` build hint to the App Group the extension writes to. File and image +items are copied into Codename One `FileSystemStorage` so your code is platform neutral. + +=== Push topics + +`Push.subscribeToTopic("news")` / `unsubscribeFromTopic("news")` subscribe the device to a +fan-out topic. + +[options="header"] +|=== +| | Android | iOS +| topic subscribe / unsubscribe | Yes (Firebase Cloud Messaging) | No-op (raw APNs has no topics; fan out server-side) +|=== + +Server-side topic delivery is handled by the Codename One push server. + +=== Simulator + +The JavaSE simulator implements all of the above so you can develop without a device. Use +the *Simulate -> Notifications and Background* menu to toggle the background constraints +(network / charging / idle / battery), run scheduled work immediately, inspect registered +channels, and inject shared content. Posted notifications appear in a panel at the top +right; tapping an action (or submitting a quick-reply) routes back through your callback +exactly as on device. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index d37aaabf56..f6078fbcbd 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -71,6 +71,8 @@ include::io.asciidoc[] include::Push-Notifications.asciidoc[] +include::Notifications-And-Background-Execution.asciidoc[] + include::Miscellaneous-Features.asciidoc[] include::performance.asciidoc[] diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index cc94d5fa2f..b14cf52869 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -284,6 +284,8 @@ public File getGradleProjectDirectory() { private boolean usesBiometrics; private boolean usesNfc; private boolean usesNfcHce; + private boolean usesForegroundService; + private boolean usesSharedContent; private boolean usesOidc; private boolean usesAppleSignIn; private boolean usesWebauthn; @@ -1303,6 +1305,13 @@ public void usesClass(String cls) { } } + if (cls.equals("com/codename1/background/ForegroundService")) { + usesForegroundService = true; + } + if (cls.equals("com/codename1/share/SharedContent")) { + usesSharedContent = true; + } + // OidcClient / SystemBrowser drive sign-in through // androidx.browser Custom Tabs on Android. Mark usage so // the gradle dep gets pulled in (see further below). @@ -2399,6 +2408,48 @@ public void usesClassMethod(String cls, String method) { String backgroundFetchService = "\n"+ "\n"; + // Constraint-aware background work always registers the JobScheduler service + // (harmless when unused). The foreground service entry is emitted only when the + // app references com.codename1.background.ForegroundService. + String backgroundWorkService = "\n"; + String foregroundServiceEntry = ""; + if (usesForegroundService) { + foregroundServicePermission = true; + String foregroundServiceType = request.getArg("android.foregroundServiceType", "dataSync"); + foregroundServiceEntry = "\n"; + } + + // Receive-shared-content: register the share receiver activity with SEND / + // SEND_MULTIPLE intent filters for the mime types named by android.shareFilter + // (comma separated). When the app references SharedContent but sets no explicit + // filter, fall back to text and any stream. Empty when the app does not opt in. + String shareFilterArg = request.getArg("android.shareFilter", ""); + if ((shareFilterArg == null || shareFilterArg.trim().length() == 0) && usesSharedContent) { + shareFilterArg = "text/plain,*/*"; + } + String shareReceiverActivity = ""; + if (shareFilterArg != null && shareFilterArg.trim().length() > 0) { + StringBuilder dataLines = new StringBuilder(); + for (String mime : shareFilterArg.split(",")) { + String m = mime.trim(); + if (m.length() > 0) { + dataLines.append(" \n"); + } + } + shareReceiverActivity = "\n" + + " \n" + + " \n" + + " \n" + + dataLines + + " \n" + + " \n" + + " \n" + + " \n" + + dataLines + + " \n" + + "\n"; + } + // Host card emulation service is generated only when the classpath // scanner saw a HostCardEmulationService reference (or the developer // set the android.hceAids build hint). The matching apduservice.xml @@ -2466,6 +2517,25 @@ public void usesClassMethod(String cls, String method) { " \n"); } + // When the Codename One ForegroundService helper is used, Android 14 (API 34) + // additionally requires the per-type FOREGROUND_SERVICE_ permission matching + // the service's foregroundServiceType (default dataSync). Inject it automatically; + // it is not a restricted permission. + if (usesForegroundService) { + String fgType = request.getArg("android.foregroundServiceType", "dataSync"); + String fgPerm = "android.permission.FOREGROUND_SERVICE_" + fgType.toUpperCase(); + permissions += permissionAdd(request, "\"" + fgPerm + "\"", + " \n"); + } + + // USE_FULL_SCREEN_INTENT (required on Android 14+ for LocalNotification.setFullScreenIntent) + // is a restricted permission Google only allows for calling/alarm apps, so it is + // opt-in via the android.fullScreenIntent build hint rather than auto-injected. + if ("true".equals(request.getArg("android.fullScreenIntent", "false"))) { + permissions += permissionAdd(request, "\"android.permission.USE_FULL_SCREEN_INTENT\"", + " \n"); + } + if (postNotificationsPermission) { permissions += permissionAdd(request, "\"android.permission.POST_NOTIFICATIONS\"", " \n"); @@ -2804,6 +2874,9 @@ public void usesClassMethod(String cls, String method) { + backgroundLocationReceiver + mediabuttonReceiver + backgroundFetchService + + backgroundWorkService + + foregroundServiceEntry + + shareReceiverActivity + locationServices + mediaService + remoteControlService @@ -2866,9 +2939,26 @@ public void usesClassMethod(String cls, String method) { + " if(intent != null && intent.getExtras() != null && intent.getExtras().containsKey(\"LocalNotificationID\")){\n" + " String id = intent.getExtras().getString(\"LocalNotificationID\");\n" + " intent.removeExtra(\"LocalNotificationID\");\n" + + " if(intent.getExtras() != null && intent.getExtras().containsKey(\"LocalNotificationActionId\")){\n" + + " com.codename1.push.PushContent.reset();\n" + + " String actionId = intent.getExtras().getString(\"LocalNotificationActionId\");\n" + + " com.codename1.push.PushContent.setActionId(actionId);\n" + + " com.codename1.push.PushContent.setActionTitle(intent.getExtras().getString(\"LocalNotificationActionTitle\"));\n" + + " intent.removeExtra(\"LocalNotificationActionId\");\n" + + " intent.removeExtra(\"LocalNotificationActionTitle\");\n" + + " if(com.codename1.impl.android.compat.app.RemoteInputWrapper.isSupported()){\n" + + " android.os.Bundle textExtras = com.codename1.impl.android.compat.app.RemoteInputWrapper.getResultsFromIntent(intent);\n" + + " if(textExtras != null){\n" + + " CharSequence cs = textExtras.getCharSequence(actionId + \"$Result\");\n" + + " if(cs != null){ com.codename1.push.PushContent.setTextResponse(cs.toString()); }\n" + + " }\n" + + " }\n" + + " }\n" + " ((com.codename1.notifications.LocalNotificationCallback)i).localNotificationReceived(id);\n" + " }\n" - + " }\n"; + + " }\n" + + " com.codename1.impl.android.AndroidImplementation.setCurrentApplicationInstance(i);\n" + + " com.codename1.impl.android.AndroidImplementation.deliverPendingSharedContent();\n"; // Install the build-time-generated @Route dispatcher before the diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 5e222a8125..f19e3fd638 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1975,6 +1975,21 @@ public void usesClassMethod(String cls, String method) { } } + // Time-sensitive / critical notification entitlements. These require a + // matching capability to be enabled on the Apple App ID, so auto-injecting them + // from mere notification usage would break code signing for apps that have not + // provisioned the capability. They are therefore opt-in via build hints: + // ios.timeSensitiveNotifications=true -> com.apple.developer.usernotifications.time-sensitive + // ios.criticalAlerts=true -> com.apple.developer.usernotifications.critical-alerts + if ("true".equals(request.getArg("ios.timeSensitiveNotifications", "false")) + && request.getArg("ios.entitlements.com.apple.developer.usernotifications.time-sensitive", null) == null) { + request.putArgument("ios.entitlements.com.apple.developer.usernotifications.time-sensitive", "true"); + } + if ("true".equals(request.getArg("ios.criticalAlerts", "false")) + && request.getArg("ios.entitlements.com.apple.developer.usernotifications.critical-alerts", null) == null) { + request.putArgument("ios.entitlements.com.apple.developer.usernotifications.critical-alerts", "true"); + } + // Deeper-network connectivity (WiFi info / NEHotspotConfiguration // / Bonjour). Each block is gated on a scanner flag so apps that // never touch the API see no entitlement or plist changes -- this @@ -2128,6 +2143,15 @@ public void usesClassMethod(String cls, String method) { } } + // BackgroundTasks.framework (BGTaskScheduler / BGProcessingTaskRequest, + // iOS 13+) is referenced unconditionally by the IOSNative background + // processing bridge, so it must always be linked. + if (addLibs == null) { + addLibs = "BackgroundTasks.framework"; + } else if (!addLibs.toLowerCase().contains("backgroundtasks")) { + addLibs += ";BackgroundTasks.framework"; + } + if (request.getArg("ios.useJavascriptCore", "false").equalsIgnoreCase("true")) { replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_JAVASCRIPTCORE", "#define CN1_USE_JAVASCRIPTCORE"); if (addLibs == null) { @@ -3517,6 +3541,21 @@ public boolean accept(File file, String string) { } } + // Constraint-aware background work / BackgroundTask map to BGTaskScheduler. The + // permitted identifiers are declared via ios.backgroundProcessingIds (comma list, + // default .processing). Their presence implies the "processing" + // background mode. + String backgroundProcessingIds = request.getArg("ios.backgroundProcessingIds", null); + if (backgroundProcessingIds == null && "true".equals(request.getArg("ios.usesBackgroundProcessing", "false"))) { + backgroundProcessingIds = request.getPackageName() + ".processing"; + } + if (backgroundProcessingIds != null && backgroundProcessingIds.trim().length() > 0) { + if (backgroundModesStr == null || !backgroundModesStr.contains("processing")) { + backgroundModesStr = (backgroundModesStr == null || backgroundModesStr.trim().length() == 0) + ? "processing" : backgroundModesStr + ",processing"; + } + } + if (backgroundModesStr != null) { String[] backgroundModes = backgroundModesStr.split(","); if (!inject.contains("UIBackgroundModes")) { @@ -3533,10 +3572,30 @@ public boolean accept(File file, String string) { inject += ""; } else { throw new IOException("You cannot use both ios.background_modes build hint and use UIBackgroundModes in the ios.plistInject build hint. Choose one or the other"); - + } } + // BGTaskScheduler permitted identifiers (iOS 13+). Required or iOS throws when the + // app registers/submits a background processing task. + if (backgroundProcessingIds != null && backgroundProcessingIds.trim().length() > 0 + && !inject.contains("BGTaskSchedulerPermittedIdentifiers")) { + inject += "\nBGTaskSchedulerPermittedIdentifiers"; + for (String id : backgroundProcessingIds.split(",")) { + if (id.trim().length() > 0) { + inject += "" + id.trim() + ""; + } + } + inject += ""; + } + + // Receive-shared-content: the host app reads the shared payload from this App Group + // suite (written by the share extension). See ios.shareAppGroup build hint. + String shareAppGroup = request.getArg("ios.shareAppGroup", null); + if (shareAppGroup != null && shareAppGroup.trim().length() > 0 && !inject.contains("CN1ShareAppGroup")) { + inject += "\nCN1ShareAppGroup" + shareAppGroup.trim() + ""; + } + BufferedReader infoReader = new BufferedReader(new InputStreamReader( Files.newInputStream(infoPlist.toPath()), StandardCharsets.UTF_8)); StringBuilder b = new StringBuilder(); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSNotificationContentExtensionBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSNotificationContentExtensionBuilder.java new file mode 100644 index 0000000000..332df58695 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSNotificationContentExtensionBuilder.java @@ -0,0 +1,341 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.util; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Generates an iOS Notification Content Extension bundle ready to be picked up by + * {@code IPhoneBuilder} during the Codename One iOS build, following the same + * {@code .ios.appext} packaging convention as {@link IOSShareExtensionBuilder}. + * + *

A notification content extension provides a custom view for notifications whose + * {@code categoryIdentifier} matches the extension's {@code UNNotificationExtensionCategory}. + * It corresponds to {@code LocalNotification.setCustomView(...)} on the Codename One side: + * the category used is {@code cn1-ln-} (see {@code IOSImplementation}).

+ * + *

The generated archive contains:

+ *
    + *
  • An {@code Info.plist} with the NSExtension dictionary, point identifier + * {@code com.apple.usernotifications.content} and the matching category.
  • + *
  • A Swift {@code NotificationViewController} implementing + * {@code UNNotificationContentExtension} that renders the notification title and + * body (and may be customized further).
  • + *
  • An optional entitlements file declaring a shared App Group.
  • + *
  • A {@code buildSettings.properties} consumed by {@code IPhoneBuilder}.
  • + *
+ * + *

This class produces ASCII-only, deterministic output.

+ */ +public final class IOSNotificationContentExtensionBuilder { + + private String extensionName = "NotificationContentExtension"; + private String displayName; + private String category = "cn1-notification"; + private String appGroupId; + private String deploymentTarget = "12.0"; + + /** Bare-bones constructor. Configure with the fluent setters. */ + public IOSNotificationContentExtensionBuilder() {} + + /** + * Sets the extension target name (Xcode target, bundle and directory name). Must be an + * ASCII identifier. + * @param name extension name + * @return this + */ + public IOSNotificationContentExtensionBuilder setExtensionName(String name) { + this.extensionName = name; + return this; + } + + /** + * Sets the user-visible name. Defaults to the extension name. + * @param name display name + * @return this + */ + public IOSNotificationContentExtensionBuilder setDisplayName(String name) { + this.displayName = name; + return this; + } + + /** + * Sets the notification category this extension renders. Must match the + * {@code categoryIdentifier} of the notifications it should display. + * @param category the category id + * @return this + */ + public IOSNotificationContentExtensionBuilder setCategory(String category) { + this.category = category; + return this; + } + + /** + * Sets an optional shared App Group id (must start with {@code group.}). + * @param appGroupId the app group + * @return this + */ + public IOSNotificationContentExtensionBuilder setAppGroupId(String appGroupId) { + this.appGroupId = appGroupId; + return this; + } + + /** + * Sets the minimum iOS deployment target. + * @param target deployment target, e.g. "12.0" + * @return this + */ + public IOSNotificationContentExtensionBuilder setDeploymentTarget(String target) { + this.deploymentTarget = target; + return this; + } + + private String getDisplayName() { + return displayName == null || displayName.length() == 0 ? extensionName : displayName; + } + + private void validate() { + if (extensionName == null || !isIdentifier(extensionName)) { + throw new IllegalStateException("extensionName must be ASCII letters/digits/_/- only: " + extensionName); + } + if (category == null || category.length() == 0) { + throw new IllegalStateException("category must be set"); + } + if (appGroupId != null && appGroupId.length() > 0 && !appGroupId.startsWith("group.")) { + throw new IllegalStateException("appGroupId must start with 'group.': " + appGroupId); + } + } + + private static boolean isIdentifier(String s) { + if (s == null || s.length() == 0) { + return false; + } + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '_' || c == '-'; + if (!ok) { + return false; + } + } + return true; + } + + /** + * Builds the in-memory file map. Public for unit testing. + * @return the file map + */ + public java.util.Map buildFileMap() { + validate(); + java.util.LinkedHashMap map = new java.util.LinkedHashMap(); + map.put("Info.plist", utf8(buildInfoPlist())); + if (appGroupId != null && appGroupId.length() > 0) { + map.put(extensionName + ".entitlements", utf8(buildEntitlements())); + } + map.put("NotificationViewController.swift", utf8(buildViewController())); + map.put("buildSettings.properties", utf8(buildBuildSettings())); + return map; + } + + private static byte[] utf8(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + private String buildInfoPlist() { + StringBuilder sb = new StringBuilder(2048); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + plistKeyString(sb, "CFBundleDevelopmentRegion", "en"); + plistKeyString(sb, "CFBundleDisplayName", getDisplayName()); + plistKeyString(sb, "CFBundleExecutable", "$(EXECUTABLE_NAME)"); + plistKeyString(sb, "CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)"); + plistKeyString(sb, "CFBundleInfoDictionaryVersion", "6.0"); + plistKeyString(sb, "CFBundleName", "$(PRODUCT_NAME)"); + plistKeyString(sb, "CFBundlePackageType", "$(PRODUCT_BUNDLE_PACKAGE_TYPE)"); + plistKeyString(sb, "CFBundleShortVersionString", "1.0"); + plistKeyString(sb, "CFBundleVersion", "1"); + sb.append(" NSExtension\n"); + sb.append(" \n"); + sb.append(" NSExtensionAttributes\n"); + sb.append(" \n"); + sb.append(" UNNotificationExtensionCategory\n"); + sb.append(" ").append(escapeXml(category)).append("\n"); + sb.append(" UNNotificationExtensionInitialContentSizeRatio\n"); + sb.append(" 1\n"); + sb.append(" \n"); + sb.append(" NSExtensionMainStoryboard\n"); + sb.append(" MainInterface\n"); + sb.append(" NSExtensionPointIdentifier\n"); + sb.append(" com.apple.usernotifications.content\n"); + sb.append(" NSExtensionPrincipalClass\n"); + sb.append(" $(PRODUCT_MODULE_NAME).NotificationViewController\n"); + sb.append(" \n"); + sb.append("\n"); + sb.append("\n"); + return sb.toString(); + } + + private String buildEntitlements() { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + sb.append("\n"); + sb.append(" com.apple.security.application-groups\n"); + sb.append(" \n"); + sb.append(" ").append(escapeXml(appGroupId)).append("\n"); + sb.append(" \n"); + sb.append("\n"); + sb.append("\n"); + return sb.toString(); + } + + private String buildViewController() { + StringBuilder sb = new StringBuilder(2048); + sb.append("import UIKit\n"); + sb.append("import UserNotifications\n"); + sb.append("import UserNotificationsUI\n"); + sb.append("\n"); + sb.append("/// Auto-generated by Codename One IOSNotificationContentExtensionBuilder.\n"); + sb.append("/// Renders a custom view for notifications in category \"").append(category).append("\".\n"); + sb.append("class NotificationViewController: UIViewController, UNNotificationContentExtension {\n"); + sb.append("\n"); + sb.append(" private let titleLabel = UILabel()\n"); + sb.append(" private let bodyLabel = UILabel()\n"); + sb.append(" private let imageView = UIImageView()\n"); + sb.append("\n"); + sb.append(" override func viewDidLoad() {\n"); + sb.append(" super.viewDidLoad()\n"); + sb.append(" titleLabel.font = UIFont.boldSystemFont(ofSize: 16)\n"); + sb.append(" titleLabel.numberOfLines = 0\n"); + sb.append(" bodyLabel.font = UIFont.systemFont(ofSize: 14)\n"); + sb.append(" bodyLabel.numberOfLines = 0\n"); + sb.append(" imageView.contentMode = .scaleAspectFit\n"); + sb.append(" let stack = UIStackView(arrangedSubviews: [titleLabel, bodyLabel, imageView])\n"); + sb.append(" stack.axis = .vertical\n"); + sb.append(" stack.spacing = 6\n"); + sb.append(" stack.translatesAutoresizingMaskIntoConstraints = false\n"); + sb.append(" view.addSubview(stack)\n"); + sb.append(" NSLayoutConstraint.activate([\n"); + sb.append(" stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 12),\n"); + sb.append(" stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12),\n"); + sb.append(" stack.topAnchor.constraint(equalTo: view.topAnchor, constant: 12),\n"); + sb.append(" stack.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -12)\n"); + sb.append(" ])\n"); + sb.append(" }\n"); + sb.append("\n"); + sb.append(" func didReceive(_ notification: UNNotification) {\n"); + sb.append(" let content = notification.request.content\n"); + sb.append(" titleLabel.text = content.title\n"); + sb.append(" bodyLabel.text = content.body\n"); + sb.append(" if let attachment = content.attachments.first, attachment.url.startAccessingSecurityScopedResource() {\n"); + sb.append(" defer { attachment.url.stopAccessingSecurityScopedResource() }\n"); + sb.append(" if let data = try? Data(contentsOf: attachment.url) {\n"); + sb.append(" imageView.image = UIImage(data: data)\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + private String buildBuildSettings() { + StringBuilder sb = new StringBuilder(); + sb.append("# Auto-generated by Codename One IOSNotificationContentExtensionBuilder.\n"); + sb.append("# Picked up by com.codename1.builders.IPhoneBuilder when the\n"); + sb.append("# enclosing .ios.appext archive is extracted into the Xcode project.\n"); + sb.append("IPHONEOS_DEPLOYMENT_TARGET=").append(deploymentTarget).append("\n"); + sb.append("SWIFT_VERSION=5.0\n"); + sb.append("ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES=YES\n"); + if (appGroupId != null && appGroupId.length() > 0) { + sb.append("CODE_SIGN_ENTITLEMENTS=").append(extensionName).append("/") + .append(extensionName).append(".entitlements\n"); + } + sb.append("INFOPLIST_FILE=").append(extensionName).append("/Info.plist\n"); + return sb.toString(); + } + + private static void plistKeyString(StringBuilder sb, String key, String value) { + sb.append(" ").append(escapeXml(key)).append("\n"); + sb.append(" ").append(escapeXml(value)).append("\n"); + } + + private static String escapeXml(String s) { + StringBuilder out = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '&': out.append("&"); break; + case '<': out.append("<"); break; + case '>': out.append(">"); break; + case '"': out.append("""); break; + case '\'': out.append("'"); break; + default: out.append(c); + } + } + return out.toString(); + } + + /** + * Writes the extension as a single {@code .ios.appext} zip archive with each entry at + * the archive root, matching what {@code IPhoneBuilder.extractAppExtensions} expects. + * @param outputZip the target archive + * @return the file map written + * @throws IOException on I/O failure + */ + public java.util.Map writeAppext(File outputZip) throws IOException { + validate(); + if (outputZip == null) { + throw new IllegalArgumentException("outputZip must not be null"); + } + File parent = outputZip.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IOException("Could not create " + parent); + } + java.util.Map files = buildFileMap(); + FileOutputStream fos = new FileOutputStream(outputZip); + try { + ZipOutputStream zos = new ZipOutputStream(fos); + try { + for (java.util.Map.Entry e : files.entrySet()) { + ZipEntry entry = new ZipEntry(e.getKey()); + zos.putNextEntry(entry); + zos.write(e.getValue()); + zos.closeEntry(); + } + } finally { + zos.close(); + } + } finally { + try { fos.close(); } catch (IOException ignore) {} + } + return files; + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSNotificationContentExtensionBuilderTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSNotificationContentExtensionBuilderTest.java new file mode 100644 index 0000000000..7cc367e57d --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSNotificationContentExtensionBuilderTest.java @@ -0,0 +1,98 @@ +package com.codename1.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.FileInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static org.junit.jupiter.api.Assertions.*; + +public class IOSNotificationContentExtensionBuilderTest { + + private IOSNotificationContentExtensionBuilder validBuilder() { + return new IOSNotificationContentExtensionBuilder() + .setExtensionName("MyNotifContent") + .setDisplayName("Custom Notification") + .setCategory("cn1-ln-reminder") + .setAppGroupId("group.com.example.myapp.shared"); + } + + @Test + void buildFileMapProducesExpectedFiles() { + Map files = validBuilder().buildFileMap(); + Set names = new HashSet(files.keySet()); + assertTrue(names.contains("Info.plist")); + assertTrue(names.contains("NotificationViewController.swift")); + assertTrue(names.contains("buildSettings.properties")); + assertTrue(names.contains("MyNotifContent.entitlements")); + } + + @Test + void infoPlistDeclaresContentExtensionPointAndCategory() { + Map files = validBuilder().buildFileMap(); + String plist = new String(files.get("Info.plist"), StandardCharsets.UTF_8); + assertTrue(plist.contains("com.apple.usernotifications.content")); + assertTrue(plist.contains("UNNotificationExtensionCategory")); + assertTrue(plist.contains("cn1-ln-reminder")); + assertTrue(plist.contains("NotificationViewController")); + } + + @Test + void entitlementsOmittedWithoutAppGroup() { + Map files = new IOSNotificationContentExtensionBuilder() + .setExtensionName("NoGroup") + .setCategory("c") + .buildFileMap(); + assertFalse(files.containsKey("NoGroup.entitlements")); + String settings = new String(files.get("buildSettings.properties"), StandardCharsets.UTF_8); + assertFalse(settings.contains("CODE_SIGN_ENTITLEMENTS")); + } + + @Test + void invalidExtensionNameRejected() { + assertThrows(IllegalStateException.class, () -> + new IOSNotificationContentExtensionBuilder().setExtensionName("bad name").setCategory("c").buildFileMap()); + } + + @Test + void appGroupMustStartWithGroupPrefix() { + assertThrows(IllegalStateException.class, () -> + new IOSNotificationContentExtensionBuilder() + .setExtensionName("Ext").setCategory("c").setAppGroupId("com.bad").buildFileMap()); + } + + @Test + void writeAppextProducesRootEntries(@TempDir Path tmp) throws Exception { + File appext = tmp.resolve("MyNotifContent.ios.appext").toFile(); + validBuilder().writeAppext(appext); + assertTrue(appext.exists()); + Set entries = new HashSet(); + ZipInputStream zis = new ZipInputStream(new FileInputStream(appext)); + try { + ZipEntry e; + while ((e = zis.getNextEntry()) != null) { + entries.add(e.getName()); + } + } finally { + zis.close(); + } + assertTrue(entries.contains("Info.plist")); + assertTrue(entries.contains("NotificationViewController.swift")); + } + + @Test + void outputIsDeterministic() { + Map a = validBuilder().buildFileMap(); + Map b = validBuilder().buildFileMap(); + assertArrayEquals(a.get("Info.plist"), b.get("Info.plist")); + assertArrayEquals(a.get("NotificationViewController.swift"), b.get("NotificationViewController.swift")); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/background/WorkRequestTest.java b/maven/core-unittests/src/test/java/com/codename1/background/WorkRequestTest.java new file mode 100644 index 0000000000..a28d7c51c8 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/background/WorkRequestTest.java @@ -0,0 +1,56 @@ +package com.codename1.background; + +import com.codename1.util.Callback; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/// Tests for the WorkRequest builder and its constraints. +class WorkRequestTest { + + public static class SampleWorker implements BackgroundWorker { + public void performWork(String workId, Map inputData, long deadline, Callback onComplete) { + onComplete.onSucess(Boolean.TRUE); + } + } + + @Test + void builderCapturesConstraintsAndData() { + WorkRequest r = WorkRequest.builder("sync", SampleWorker.class) + .setRequiresNetwork(true) + .setRequiresCharging(true) + .setRequiresBatteryNotLow(true) + .setPeriodic(6 * 60 * 60 * 1000L) + .setInitialDelay(5000L) + .putInputData("account", "primary") + .build(); + assertEquals("sync", r.getId()); + assertEquals(SampleWorker.class.getName(), r.getWorkerClass()); + assertTrue(r.isRequiresNetwork()); + assertTrue(r.isRequiresCharging()); + assertTrue(r.isRequiresBatteryNotLow()); + assertFalse(r.isRequiresIdle()); + assertTrue(r.isPeriodic()); + assertEquals(6 * 60 * 60 * 1000L, r.getMinIntervalMillis()); + assertEquals(5000L, r.getInitialDelayMillis()); + assertEquals("primary", r.getInputData().get("account")); + } + + @Test + void oneShotByDefault() { + WorkRequest r = WorkRequest.builder("once", SampleWorker.class).build(); + assertFalse(r.isPeriodic()); + assertEquals(0L, r.getMinIntervalMillis()); + assertTrue(r.getInputData().isEmpty()); + } + + @Test + void inputDataCopyIsDefensive() { + WorkRequest r = WorkRequest.builder("x", SampleWorker.class).putInputData("k", "v").build(); + Map copy = r.getInputData(); + copy.put("k", "mutated"); + assertEquals("v", r.getInputData().get("k")); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/notifications/LocalNotificationEnrichmentTest.java b/maven/core-unittests/src/test/java/com/codename1/notifications/LocalNotificationEnrichmentTest.java new file mode 100644 index 0000000000..2d7b105b4f --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/notifications/LocalNotificationEnrichmentTest.java @@ -0,0 +1,105 @@ +package com.codename1.notifications; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/// Exercises the backward-compatible enrichment of LocalNotification (actions, +/// messaging style, channel, grouping, progress and the fluent setters) without +/// requiring a running Display. +class LocalNotificationEnrichmentTest { + + @Test + void existingFieldsRemainDefaulted() { + LocalNotification n = new LocalNotification(); + assertEquals("", n.getId()); + assertEquals(-1, n.getBadgeNumber()); + assertTrue(n.getActions().isEmpty()); + assertNull(n.getChannelId()); + assertNull(n.getGroupId()); + assertNull(n.getMessagingStyle()); + assertFalse(n.isOngoing()); + assertFalse(n.isTimeSensitive()); + assertFalse(n.isFullScreenIntent()); + assertEquals(0, n.getProgressMax()); + } + + @Test + void fluentSettersChainAndStore() { + LocalNotification n = new LocalNotification() + .setChannelId("messages") + .setGroup("chat-42") + .setGroupSummary(true) + .setFullScreenIntent(true) + .setTimeSensitive(true) + .setOngoing(true) + .setCustomView("my_layout") + .setProgress(100, 40); + assertEquals("messages", n.getChannelId()); + assertEquals("chat-42", n.getGroupId()); + assertTrue(n.isGroupSummary()); + assertTrue(n.isFullScreenIntent()); + assertTrue(n.isTimeSensitive()); + assertTrue(n.isOngoing()); + assertEquals("my_layout", n.getCustomView()); + assertEquals(100, n.getProgressMax()); + assertEquals(40, n.getProgress()); + assertFalse(n.isProgressIndeterminate()); + } + + @Test + void indeterminateProgressOverridesDeterminate() { + LocalNotification n = new LocalNotification().setProgress(100, 50).setIndeterminateProgress(true); + assertTrue(n.isProgressIndeterminate()); + } + + @Test + void setProgressClearsIndeterminate() { + LocalNotification n = new LocalNotification().setIndeterminateProgress(true).setProgress(10, 5); + assertFalse(n.isProgressIndeterminate()); + } + + @Test + void actionsAreStoredInOrder() { + LocalNotification n = new LocalNotification() + .addAction("accept", "Accept") + .addAction(new LocalNotification.Action("decline", "Decline", "ic_decline")); + List actions = n.getActions(); + assertEquals(2, actions.size()); + assertEquals("accept", actions.get(0).getId()); + assertEquals("Accept", actions.get(0).getTitle()); + assertFalse(actions.get(0).isTextInput()); + assertEquals("decline", actions.get(1).getId()); + assertEquals("ic_decline", actions.get(1).getIcon()); + } + + @Test + void inputActionIsMarkedAsTextInput() { + LocalNotification n = new LocalNotification() + .addInputAction("reply", "Reply", "Type a message", "Send"); + LocalNotification.Action a = n.getActions().get(0); + assertTrue(a.isTextInput()); + assertEquals("Type a message", a.getTextInputPlaceholder()); + assertEquals("Send", a.getTextInputButtonText()); + } + + @Test + void messagingStyleCollectsMessages() { + LocalNotification n = new LocalNotification(); + LocalNotification.MessagingStyle style = n.asMessagingStyle("Me") + .conversationTitle("Team") + .groupConversation(true) + .addMessage("hi", 1000L, "Alice") + .addMessage("hello", 2000L, null); + assertSame(style, n.getMessagingStyle()); + assertEquals("Me", style.getSelfDisplayName()); + assertEquals("Team", style.getConversationTitle()); + assertTrue(style.isGroupConversation()); + assertEquals(2, style.getMessages().size()); + assertEquals("Alice", style.getMessages().get(0).getSenderName()); + assertEquals(2000L, style.getMessages().get(1).getTimestamp()); + assertNull(style.getMessages().get(1).getSenderName()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/notifications/NotificationApiTest.java b/maven/core-unittests/src/test/java/com/codename1/notifications/NotificationApiTest.java new file mode 100644 index 0000000000..8376b358df --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/notifications/NotificationApiTest.java @@ -0,0 +1,89 @@ +package com.codename1.notifications; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/// Tests for the notification permission request/result objects and the channel builder. +class NotificationApiTest { + + @Test + void defaultPermissionRequestRequestsAlertSoundBadge() { + NotificationPermissionRequest req = new NotificationPermissionRequest(); + // alert=4, sound=2, badge=1 -> 7 + assertEquals(7, req.toAuthorizationOptionsMask()); + assertTrue(req.isAlert()); + assertTrue(req.isSound()); + assertTrue(req.isBadge()); + assertFalse(req.isProvisional()); + } + + @Test + void permissionRequestMaskMatchesUNAuthorizationOptionBits() { + NotificationPermissionRequest req = new NotificationPermissionRequest() + .alert(false).sound(false).badge(false) + .provisional(true); + // provisional bit is 64 + assertEquals(64, req.toAuthorizationOptionsMask()); + req.critical(true); // 16 + assertEquals(64 + 16, req.toAuthorizationOptionsMask()); + } + + @Test + void permissionResultGrantedSemantics() { + NotificationPermissionResult authorized = + new NotificationPermissionResult(NotificationPermissionResult.AuthorizationLevel.AUTHORIZED); + assertTrue(authorized.isGranted()); + assertFalse(authorized.isProvisional()); + + NotificationPermissionResult provisional = + new NotificationPermissionResult(NotificationPermissionResult.AuthorizationLevel.PROVISIONAL); + assertTrue(provisional.isProvisional()); + assertTrue(provisional.isGranted()); + + NotificationPermissionResult denied = + new NotificationPermissionResult(NotificationPermissionResult.AuthorizationLevel.DENIED); + assertFalse(denied.isGranted()); + assertEquals(NotificationPermissionResult.AuthorizationLevel.DENIED, denied.getAuthorizationLevel()); + + NotificationPermissionResult notDetermined = + new NotificationPermissionResult(NotificationPermissionResult.AuthorizationLevel.NOT_DETERMINED); + assertFalse(notDetermined.isGranted()); + } + + @Test + void channelBuilderStoresConfiguration() { + long[] pattern = new long[]{0, 200, 100, 200}; + NotificationChannelBuilder b = new NotificationChannelBuilder("messages", "Messages") + .description("Chat messages") + .importance(NotificationChannelBuilder.IMPORTANCE_HIGH) + .sound("/notification_sound_ping.mp3") + .vibrationPattern(pattern) + .lightColor(0xff0000) + .lockscreenVisibility(NotificationChannelBuilder.VISIBILITY_PUBLIC) + .group("chats") + .showBadge(false); + assertEquals("messages", b.getId()); + assertEquals("Messages", b.getName()); + assertEquals("Chat messages", b.getDescription()); + assertEquals(NotificationChannelBuilder.IMPORTANCE_HIGH, b.getImportance()); + assertEquals("/notification_sound_ping.mp3", b.getSound()); + assertTrue(b.isVibrationEnabled()); + assertArrayEquals(pattern, b.getVibrationPattern()); + assertTrue(b.isLightsEnabled()); + assertEquals(0xff0000, b.getLightColor()); + assertEquals(NotificationChannelBuilder.VISIBILITY_PUBLIC, b.getLockscreenVisibility()); + assertEquals("chats", b.getGroup()); + assertFalse(b.isShowBadge()); + } + + @Test + void channelBuilderDefaults() { + NotificationChannelBuilder b = new NotificationChannelBuilder("c", "C"); + assertEquals(NotificationChannelBuilder.IMPORTANCE_DEFAULT, b.getImportance()); + assertEquals(NotificationChannelBuilder.VISIBILITY_PRIVATE, b.getLockscreenVisibility()); + assertTrue(b.isShowBadge()); + assertFalse(b.isVibrationEnabled()); + assertFalse(b.isLightsEnabled()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/push/PushContentActionTitleTest.java b/maven/core-unittests/src/test/java/com/codename1/push/PushContentActionTitleTest.java new file mode 100644 index 0000000000..8f7b3ff35d --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/push/PushContentActionTitleTest.java @@ -0,0 +1,32 @@ +package com.codename1.push; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; + +import static org.junit.jupiter.api.Assertions.*; + +/// Verifies the actionTitle parity property added to PushContent so push and local +/// notification actions surface the chosen action title consistently. +class PushContentActionTitleTest extends UITestBase { + + @FormTest + void actionTitleRoundTrips() { + PushContent.reset(); + assertFalse(PushContent.exists()); + + PushContent.setActionId("reply"); + PushContent.setActionTitle("Reply"); + PushContent.setTextResponse("on my way"); + + assertTrue(PushContent.exists()); + PushContent content = PushContent.get(); + assertNotNull(content); + assertEquals("reply", content.getActionId()); + assertEquals("Reply", content.getActionTitle()); + assertEquals("on my way", content.getTextResponse()); + + // get() pops the content; a second get must return null + assertNull(PushContent.get()); + assertFalse(PushContent.exists()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/share/SharedContentTest.java b/maven/core-unittests/src/test/java/com/codename1/share/SharedContentTest.java new file mode 100644 index 0000000000..2d6fd292c3 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/share/SharedContentTest.java @@ -0,0 +1,52 @@ +package com.codename1.share; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/// Tests for the SharedContent value object and its builder. +class SharedContentTest { + + @Test + void emptyContentHasNoItems() { + SharedContent c = SharedContent.builder().build(); + assertEquals(0, c.getItems().length); + assertNull(c.getFirstItem()); + assertFalse(c.hasText()); + assertFalse(c.hasFiles()); + assertNull(c.getSubject()); + } + + @Test + void textAndUrlClassifiedAsText() { + SharedContent c = SharedContent.builder() + .subject("Re: hi") + .addText("hello") + .addUrl("https://codenameone.com") + .build(); + assertEquals("Re: hi", c.getSubject()); + assertEquals(2, c.getItems().length); + assertTrue(c.hasText()); + assertFalse(c.hasFiles()); + assertEquals(SharedContent.TYPE_TEXT, c.getFirstItem().getType()); + assertEquals("hello", c.getFirstItem().getText()); + assertEquals(SharedContent.TYPE_URL, c.getItems()[1].getType()); + } + + @Test + void imageAndFileClassifiedAsFiles() { + SharedContent c = SharedContent.builder() + .addImage("image/png", "file:///shared/pic.png", "pic.png") + .addFile("application/pdf", "file:///shared/doc.pdf", "doc.pdf") + .build(); + assertTrue(c.hasFiles()); + assertFalse(c.hasText()); + SharedContent.Item img = c.getItems()[0]; + assertEquals(SharedContent.TYPE_IMAGE, img.getType()); + assertEquals("image/png", img.getMimeType()); + assertEquals("file:///shared/pic.png", img.getFilePath()); + assertEquals("pic.png", img.getTitle()); + assertNull(img.getText()); + assertEquals(SharedContent.TYPE_FILE, c.getItems()[1].getType()); + } +}