From 805ff10d62782d5c26e0c9c7607417ef4f39aa4f Mon Sep 17 00:00:00 2001 From: BastienRdz Date: Tue, 27 Jan 2026 13:06:27 +0100 Subject: [PATCH] feat: add Live Activities (iOS) and Timer Notifications (Android) support - Add startLiveActivity, updateLiveActivity, endLiveActivity methods - Add getActiveLiveActivities for listing active activities - Add liveActivityEnded event listener - Add chronometer support for Android notifications - Add silentUpdate option for Android - Add changeExactNotificationSetting for Android 12+ - Add iosCustomDismissAction for iOS notification categories - Upgrade iOS deployment target to 16.2 --- .../CapacitorLocalNotifications.podspec | 2 +- local-notifications/Package.swift | 2 +- local-notifications/README.md | 223 +++++++- .../android/src/main/AndroidManifest.xml | 14 + .../localnotifications/LocalNotification.java | 56 ++ .../LocalNotificationManager.java | 23 + .../LocalNotificationsPlugin.java | 530 ++++++++++++++++++ .../localnotifications/TimerEndReceiver.java | 39 ++ .../TimerProgressService.java | 444 +++++++++++++++ .../LocalNotificationsHandler.swift | 19 +- .../LocalNotificationsPlugin.swift | 277 ++++++++- local-notifications/package.json | 6 +- local-notifications/src/definitions.ts | 355 ++++++++++-- local-notifications/src/web.ts | 47 +- local-notifications/tsconfig.json | 3 +- 15 files changed, 1948 insertions(+), 92 deletions(-) create mode 100644 local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/TimerEndReceiver.java create mode 100644 local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/TimerProgressService.java diff --git a/local-notifications/CapacitorLocalNotifications.podspec b/local-notifications/CapacitorLocalNotifications.podspec index ca4168ca8..c93207531 100644 --- a/local-notifications/CapacitorLocalNotifications.podspec +++ b/local-notifications/CapacitorLocalNotifications.podspec @@ -11,7 +11,7 @@ Pod::Spec.new do |s| s.author = package['author'] s.source = { :git => 'https://github.com/ionic-team/capacitor-plugins.git', :tag => package['name'] + '@' + package['version'] } s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}', 'local-notifications/ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}' - s.ios.deployment_target = '14.0' + s.ios.deployment_target = '16.2' s.dependency 'Capacitor' s.swift_version = '5.1' end diff --git a/local-notifications/Package.swift b/local-notifications/Package.swift index 7de14b6a2..41150a4f6 100644 --- a/local-notifications/Package.swift +++ b/local-notifications/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "CapacitorLocalNotifications", - platforms: [.iOS(.v14)], + platforms: [.iOS(.v16)], products: [ .library( name: "CapacitorLocalNotifications", diff --git a/local-notifications/README.md b/local-notifications/README.md index eedfb418e..3345cb8ee 100644 --- a/local-notifications/README.md +++ b/local-notifications/README.md @@ -107,7 +107,12 @@ If the device has entered [Doze](https://developer.android.com/training/monitori * [`checkExactNotificationSetting()`](#checkexactnotificationsetting) * [`addListener('localNotificationReceived', ...)`](#addlistenerlocalnotificationreceived-) * [`addListener('localNotificationActionPerformed', ...)`](#addlistenerlocalnotificationactionperformed-) +* [`addListener('liveActivityEnded', ...)`](#addlistenerliveactivityended-) * [`removeAllListeners()`](#removealllisteners) +* [`startLiveActivity(...)`](#startliveactivity) +* [`updateLiveActivity(...)`](#updateliveactivity) +* [`endLiveActivity(...)`](#endliveactivity) +* [`getActiveLiveActivities()`](#getactiveliveactivities) * [Interfaces](#interfaces) * [Type Aliases](#type-aliases) * [Enums](#enums) @@ -412,6 +417,26 @@ Listen for when an action is performed on a notification. -------------------- +### addListener('liveActivityEnded', ...) + +```typescript +addListener(eventName: 'liveActivityEnded', listenerFunc: (event: LiveActivityEndedEvent) => void) => Promise +``` + +Listen for when a Live Activity timer ends. + +| Param | Type | +| ------------------ | --------------------------------------------------------------------------------------------- | +| **`eventName`** | 'liveActivityEnded' | +| **`listenerFunc`** | (event: LiveActivityEndedEvent) => void | + +**Returns:** Promise<PluginListenerHandle> + +**Since:** 7.1.0 + +-------------------- + + ### removeAllListeners() ```typescript @@ -425,6 +450,80 @@ Remove all listeners for this plugin. -------------------- +### startLiveActivity(...) + +```typescript +startLiveActivity(options: LiveActivityOptions) => Promise +``` + +Start a Live Activity (iOS) or Timer Notification (Android). +Uses native system APIs for timer display - works in background. + +On Android: Uses setUsesChronometer() for native timer display. +On iOS: Uses ActivityKit with Text(timerInterval:) for native timer. + +| Param | Type | +| ------------- | ------------------------------------------------------------------- | +| **`options`** | LiveActivityOptions | + +**Returns:** Promise<LiveActivityResult> + +**Since:** 7.1.0 + +-------------------- + + +### updateLiveActivity(...) + +```typescript +updateLiveActivity(options: UpdateLiveActivityOptions) => Promise +``` + +Update a Live Activity content. +On iOS: Updates the Live Activity content state. +On Android: Updates the notification title/body (timer continues automatically). + +| Param | Type | +| ------------- | ------------------------------------------------------------------------------- | +| **`options`** | UpdateLiveActivityOptions | + +**Since:** 7.1.0 + +-------------------- + + +### endLiveActivity(...) + +```typescript +endLiveActivity(options: EndLiveActivityOptions) => Promise +``` + +End/dismiss a Live Activity or Timer Notification. + +| Param | Type | +| ------------- | ------------------------------------------------------------------------- | +| **`options`** | EndLiveActivityOptions | + +**Since:** 7.1.0 + +-------------------- + + +### getActiveLiveActivities() + +```typescript +getActiveLiveActivities() => Promise +``` + +Get list of active Live Activities/Timer Notifications. + +**Returns:** Promise<ActiveLiveActivitiesResult> + +**Since:** 7.1.0 + +-------------------- + + ### Interfaces @@ -453,30 +552,32 @@ The object that describes a local notification. #### LocalNotificationSchema -| Prop | Type | Description | Since | -| ---------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | -| **`title`** | string | The title of the notification. | 1.0.0 | -| **`body`** | string | The body of the notification, shown below the title. | 1.0.0 | -| **`largeBody`** | string | Sets a multiline text block for display in a big text notification style. | 1.0.0 | -| **`summaryText`** | string | Used to set the summary text detail in inbox and big text notification styles. Only available for Android. | 1.0.0 | -| **`id`** | number | The notification identifier. On Android it's a 32-bit int. So the value should be between -2147483648 and 2147483647 inclusive. | 1.0.0 | -| **`schedule`** | Schedule | Schedule this notification for a later time. | 1.0.0 | -| **`sound`** | string | Name of the audio file to play when this notification is displayed. Include the file extension with the filename. On iOS, the file should be in the app bundle. On Android, the file should be in res/raw folder. Recommended format is `.wav` because is supported by both iOS and Android. Only available for iOS and Android < 26. For Android 26+ use channelId of a channel configured with the desired sound. If the sound file is not found, (i.e. empty string or wrong name) the default system notification sound will be used. If not provided, it will produce the default sound on Android and no sound on iOS. | 1.0.0 | -| **`smallIcon`** | string | Set a custom status bar icon. If set, this overrides the `smallIcon` option from Capacitor configuration. Icons should be placed in your app's `res/drawable` folder. The value for this option should be the drawable resource ID, which is the filename without an extension. Only available for Android. | 1.0.0 | -| **`largeIcon`** | string | Set a large icon for notifications. Icons should be placed in your app's `res/drawable` folder. The value for this option should be the drawable resource ID, which is the filename without an extension. Only available for Android. | 1.0.0 | -| **`iconColor`** | string | Set the color of the notification icon. Only available for Android. | 1.0.0 | -| **`attachments`** | Attachment[] | Set attachments for this notification. | 1.0.0 | -| **`actionTypeId`** | string | Associate an action type with this notification. | 1.0.0 | -| **`extra`** | any | Set extra data to store within this notification. | 1.0.0 | -| **`threadIdentifier`** | string | Used to group multiple notifications. Sets `threadIdentifier` on the [`UNMutableNotificationContent`](https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent). Only available for iOS. | 1.0.0 | -| **`summaryArgument`** | string | The string this notification adds to the category's summary format string. Sets `summaryArgument` on the [`UNMutableNotificationContent`](https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent). Only available for iOS. | 1.0.0 | -| **`group`** | string | Used to group multiple notifications. Calls `setGroup()` on [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) with the provided value. Only available for Android. | 1.0.0 | -| **`groupSummary`** | boolean | If true, this notification becomes the summary for a group of notifications. Calls `setGroupSummary()` on [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) with the provided value. Only available for Android when using `group`. | 1.0.0 | -| **`channelId`** | string | Specifies the channel the notification should be delivered on. If channel with the given name does not exist then the notification will not fire. If not provided, it will use the default channel. Calls `setChannelId()` on [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) with the provided value. Only available for Android 26+. | 1.0.0 | -| **`ongoing`** | boolean | If true, the notification can't be swiped away. Calls `setOngoing()` on [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) with the provided value. Only available for Android. | 1.0.0 | -| **`autoCancel`** | boolean | If true, the notification is canceled when the user clicks on it. Calls `setAutoCancel()` on [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) with the provided value. Only available for Android. | 1.0.0 | -| **`inboxList`** | string[] | Sets a list of strings for display in an inbox style notification. Up to 5 strings are allowed. Only available for Android. | 1.0.0 | -| **`silent`** | boolean | If true, notification will not appear while app is in the foreground. Only available for iOS. | 5.0.0 | +| Prop | Type | Description | Since | +| ---------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| **`title`** | string | The title of the notification. | 1.0.0 | +| **`body`** | string | The body of the notification, shown below the title. | 1.0.0 | +| **`largeBody`** | string | Sets a multiline text block for display in a big text notification style. | 1.0.0 | +| **`summaryText`** | string | Used to set the summary text detail in inbox and big text notification styles. Only available for Android. | 1.0.0 | +| **`id`** | number | The notification identifier. On Android it's a 32-bit int. So the value should be between -2147483648 and 2147483647 inclusive. | 1.0.0 | +| **`chronometer`** | { enabled: boolean; countDown?: boolean; when?: number; } | Display a native chronometer in the notification. The system handles the timer display automatically. Only available for Android. | 7.1.0 | +| **`silentUpdate`** | boolean | If true, updates to this notification will not make sound or vibrate. Useful for ongoing notifications that update frequently. Only available for Android. | 7.1.0 | +| **`schedule`** | Schedule | Schedule this notification for a later time. | 1.0.0 | +| **`sound`** | string | Name of the audio file to play when this notification is displayed. Include the file extension with the filename. On iOS, the file should be in the app bundle. On Android, the file should be in res/raw folder. Recommended format is `.wav` because is supported by both iOS and Android. Only available for iOS and Android < 26. For Android 26+ use channelId of a channel configured with the desired sound. If the sound file is not found, (i.e. empty string or wrong name) the default system notification sound will be used. If not provided, it will produce the default sound on Android and no sound on iOS. | 1.0.0 | +| **`smallIcon`** | string | Set a custom status bar icon. If set, this overrides the `smallIcon` option from Capacitor configuration. Icons should be placed in your app's `res/drawable` folder. The value for this option should be the drawable resource ID, which is the filename without an extension. Only available for Android. | 1.0.0 | +| **`largeIcon`** | string | Set a large icon for notifications. Icons should be placed in your app's `res/drawable` folder. The value for this option should be the drawable resource ID, which is the filename without an extension. Only available for Android. | 1.0.0 | +| **`iconColor`** | string | Set the color of the notification icon. Only available for Android. | 1.0.0 | +| **`attachments`** | Attachment[] | Set attachments for this notification. | 1.0.0 | +| **`actionTypeId`** | string | Associate an action type with this notification. | 1.0.0 | +| **`extra`** | any | Set extra data to store within this notification. | 1.0.0 | +| **`threadIdentifier`** | string | Used to group multiple notifications. Sets `threadIdentifier` on the [`UNMutableNotificationContent`](https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent). Only available for iOS. | 1.0.0 | +| **`summaryArgument`** | string | The string this notification adds to the category's summary format string. Sets `summaryArgument` on the [`UNMutableNotificationContent`](https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent). Only available for iOS. | 1.0.0 | +| **`group`** | string | Used to group multiple notifications. Calls `setGroup()` on [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) with the provided value. Only available for Android. | 1.0.0 | +| **`groupSummary`** | boolean | If true, this notification becomes the summary for a group of notifications. Calls `setGroupSummary()` on [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) with the provided value. Only available for Android when using `group`. | 1.0.0 | +| **`channelId`** | string | Specifies the channel the notification should be delivered on. If channel with the given name does not exist then the notification will not fire. If not provided, it will use the default channel. Calls `setChannelId()` on [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) with the provided value. Only available for Android 26+. | 1.0.0 | +| **`ongoing`** | boolean | If true, the notification can't be swiped away. Calls `setOngoing()` on [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) with the provided value. Only available for Android. | 1.0.0 | +| **`autoCancel`** | boolean | If true, the notification is canceled when the user clicks on it. Calls `setAutoCancel()` on [`NotificationCompat.Builder`](https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder) with the provided value. Only available for Android. | 1.0.0 | +| **`inboxList`** | string[] | Sets a list of strings for display in an inbox style notification. Up to 5 strings are allowed. Only available for Android. | 1.0.0 | +| **`silent`** | boolean | If true, notification will not appear while app is in the foreground. Only available for iOS. | 5.0.0 | #### Schedule @@ -727,6 +828,71 @@ An action that can be taken when a notification is displayed. | **`notification`** | LocalNotificationSchema | The original notification schema. | 1.0.0 | +#### LiveActivityEndedEvent + +Event fired when a Live Activity timer ends. + +| Prop | Type | Description | +| ---------------- | ------------------- | ----------------------------------- | +| **`activityId`** | string | The activity identifier that ended. | + + +#### LiveActivityResult + +Result from starting a Live Activity. + +| Prop | Type | Description | +| -------------------- | ------------------- | ----------------------------------------------- | +| **`activityId`** | string | The activity identifier. | +| **`notificationId`** | number | Notification ID for Android (for internal use). | + + +#### LiveActivityOptions + +Options for starting a Live Activity (iOS) or Timer Notification (Android). + +| Prop | Type | Description | +| ---------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| **`id`** | string | Unique identifier for the activity. | +| **`title`** | string | Display title. | +| **`message`** | string | Display message/body. | +| **`timer`** | { mode: 'countdown' \| 'elapsed'; targetTimestamp: number; alertOnEnd?: boolean; } | Timer configuration (optional). If provided, the system handles the timer display automatically. | +| **`contentState`** | Record<string, string> | Dynamic values (updated via updateLiveActivity). All values must be strings for iOS compatibility. | +| **`staticAttributes`** | Record<string, string> | Static values (set once at start, cannot be updated). | +| **`channelId`** | string | Channel ID for Android notifications. | +| **`smallIcon`** | string | Small icon for Android notifications. | + + +#### UpdateLiveActivityOptions + +Options for updating a Live Activity. + +| Prop | Type | Description | +| ------------------ | --------------------------------------------------------------- | --------------------------------- | +| **`id`** | string | The activity identifier. | +| **`title`** | string | Updated title (optional). | +| **`message`** | string | Updated message (optional). | +| **`contentState`** | Record<string, string> | Updated content state (optional). | + + +#### EndLiveActivityOptions + +Options for ending a Live Activity. + +| Prop | Type | Description | +| -------- | ------------------- | ------------------------ | +| **`id`** | string | The activity identifier. | + + +#### ActiveLiveActivitiesResult + +Result from getting active Live Activities. + +| Prop | Type | Description | +| ---------------- | --------------------- | ---------------------------- | +| **`activities`** | string[] | List of active activity IDs. | + + ### Type Aliases @@ -754,6 +920,15 @@ The notification visibility. For more details, see the [Android Developer Docs]( 'prompt' | 'prompt-with-rationale' | 'granted' | 'denied' +#### Record + +Construct a type with a set of properties K of type T + +{ + [P in K]: T; + } + + ### Enums diff --git a/local-notifications/android/src/main/AndroidManifest.xml b/local-notifications/android/src/main/AndroidManifest.xml index 04e785d44..b3e85b3ba 100644 --- a/local-notifications/android/src/main/AndroidManifest.xml +++ b/local-notifications/android/src/main/AndroidManifest.xml @@ -13,8 +13,22 @@ + + + + + + + + diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotification.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotification.java index 7463256d4..41c5770f2 100644 --- a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotification.java +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotification.java @@ -41,6 +41,12 @@ public class LocalNotification { private LocalNotificationSchedule schedule; private String channelId; private String source; + + // Chronometer support for native timer display + private boolean chronometerEnabled; + private boolean chronometerCountDown; + private long chronometerWhen; + private boolean silentUpdate; public String getTitle() { return title; @@ -203,6 +209,43 @@ public void setChannelId(String channelId) { this.channelId = channelId; } + // Chronometer getters and setters + public boolean isChronometerEnabled() { + return chronometerEnabled; + } + + public void setChronometerEnabled(boolean chronometerEnabled) { + this.chronometerEnabled = chronometerEnabled; + } + + public boolean isChronometerCountDown() { + return chronometerCountDown; + } + + public void setChronometerCountDown(boolean chronometerCountDown) { + this.chronometerCountDown = chronometerCountDown; + } + + public long getChronometerWhen() { + return chronometerWhen; + } + + public void setChronometerWhen(long chronometerWhen) { + this.chronometerWhen = chronometerWhen; + } + + public boolean isSilentUpdate() { + return silentUpdate; + } + + public void setSilentUpdate(boolean silentUpdate) { + this.silentUpdate = silentUpdate; + } + + public boolean hasChronometer() { + return chronometerEnabled; + } + /** * Build list of the notifications from remote plugin call */ @@ -270,6 +313,19 @@ public static LocalNotification buildNotificationFromJSObject(JSObject jsonObjec localNotification.setExtra(jsonObject.getJSObject("extra")); localNotification.setOngoing(jsonObject.getBoolean("ongoing", false)); localNotification.setAutoCancel(jsonObject.getBoolean("autoCancel", true)); + localNotification.setSilentUpdate(jsonObject.getBoolean("silentUpdate", false)); + + // Parse chronometer settings + JSObject chronometerObj = jsonObject.getJSObject("chronometer"); + if (chronometerObj != null) { + localNotification.setChronometerEnabled(chronometerObj.getBoolean("enabled", false)); + localNotification.setChronometerCountDown(chronometerObj.getBoolean("countDown", false)); + // Use optLong to avoid JSONException - returns 0 if not found + long when = chronometerObj.optLong("when", 0); + if (when > 0) { + localNotification.setChronometerWhen(when); + } + } try { JSONArray inboxList = jsonObject.getJSONArray("inboxList"); diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationManager.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationManager.java index 0b826a546..089769c58 100644 --- a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationManager.java +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationManager.java @@ -214,6 +214,29 @@ private void buildNotification(NotificationManagerCompat notificationManager, Lo mBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE); mBuilder.setOnlyAlertOnce(true); + // Chronometer support for native timer display + if (localNotification.hasChronometer()) { + mBuilder.setUsesChronometer(true); + + long when = localNotification.getChronometerWhen(); + if (when > 0) { + mBuilder.setWhen(when); + } + + // API 24+ for countdown mode + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && localNotification.isChronometerCountDown()) { + mBuilder.setChronometerCountDown(true); + } + } + + // Silent update support - don't make sound/vibrate on update + if (localNotification.isSilentUpdate()) { + mBuilder.setOnlyAlertOnce(true); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + mBuilder.setSilent(true); + } + } + mBuilder.setSmallIcon(localNotification.getSmallIcon(context, getDefaultSmallIcon(context))); mBuilder.setLargeIcon(localNotification.getLargeIcon(context)); diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationsPlugin.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationsPlugin.java index c9f09e59c..0d62ef458 100644 --- a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationsPlugin.java +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/LocalNotificationsPlugin.java @@ -6,12 +6,14 @@ import android.app.AlarmManager; import android.app.Notification; import android.app.NotificationManager; +import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.service.notification.StatusBarNotification; import androidx.activity.result.ActivityResult; +import androidx.core.app.NotificationCompat; import com.getcapacitor.Bridge; import com.getcapacitor.JSArray; import com.getcapacitor.JSObject; @@ -24,6 +26,7 @@ import com.getcapacitor.annotation.CapacitorPlugin; import com.getcapacitor.annotation.Permission; import com.getcapacitor.annotation.PermissionCallback; +import java.util.HashMap; import java.util.List; import java.util.Map; import org.json.JSONArray; @@ -37,12 +40,43 @@ public class LocalNotificationsPlugin extends Plugin { static final String LOCAL_NOTIFICATIONS = "display"; + private static final String LIVE_ACTIVITY_CHANNEL_ID = "live_activity"; private static Bridge staticBridge = null; private LocalNotificationManager manager; public NotificationManager notificationManager; private NotificationStorage notificationStorage; private NotificationChannelManager notificationChannelManager; + + // Store active live activities: activityId -> LiveActivityConfig + private final Map activeLiveActivities = new HashMap<>(); + + // Configuration d'une Live Activity pour pouvoir la mettre à jour + private static class LiveActivityConfig { + int notificationId; + String title; + String message; + String channelId; + String actionTypeId; + JSObject timer; + long startTimestamp; + long maxDurationMs; + boolean hasProgressService; + + LiveActivityConfig(int notificationId, String title, String message, String channelId, + String actionTypeId, JSObject timer, long startTimestamp, long maxDurationMs, + boolean hasProgressService) { + this.notificationId = notificationId; + this.title = title; + this.message = message; + this.channelId = channelId; + this.actionTypeId = actionTypeId; + this.timer = timer; + this.startTimestamp = startTimestamp; + this.maxDurationMs = maxDurationMs; + this.hasProgressService = hasProgressService; + } + } @Override public void load() { @@ -53,6 +87,35 @@ public void load() { notificationChannelManager = new NotificationChannelManager(getActivity()); notificationManager = (NotificationManager) getActivity().getSystemService(Context.NOTIFICATION_SERVICE); staticBridge = this.bridge; + + // Handle notification action from launch intent (when app was closed) + handleLaunchIntent(); + } + + /** + * Handle notification action from the launch intent. + * This is called when the app is launched from a notification action + * while the app was completely closed. + */ + private void handleLaunchIntent() { + if (getActivity() == null) return; + + Intent launchIntent = getActivity().getIntent(); + if (launchIntent == null) return; + + // Check if this intent contains notification action data + if (launchIntent.hasExtra(LocalNotificationManager.ACTION_INTENT_KEY)) { + // Delay processing to ensure bridge is ready + getActivity().runOnUiThread(() -> { + // Small delay to ensure JavaScript is ready + new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { + JSObject dataJson = manager.handleNotificationActionPerformed(launchIntent, notificationStorage); + if (dataJson != null) { + notifyListeners("localNotificationActionPerformed", dataJson, true); + } + }, 500); + }); + } } @Override @@ -66,6 +129,32 @@ protected void handleOnNewIntent(Intent data) { notifyListeners("localNotificationActionPerformed", dataJson, true); } } + + @Override + protected void handleOnDestroy() { + super.handleOnDestroy(); + // End all Live Activities when app is destroyed + endAllLiveActivities(); + } + + /** + * End all active Live Activities. + * Called when the app is destroyed to clean up ongoing notifications. + */ + private void endAllLiveActivities() { + // Stop the timer progress service + TimerProgressService.stopTimer(getContext()); + + // Cancel all ongoing notifications + for (String activityId : activeLiveActivities.keySet()) { + LiveActivityConfig config = activeLiveActivities.get(activityId); + if (config != null) { + notificationManager.cancel(config.notificationId); + cancelTimerEndAlarm(activityId); + } + } + activeLiveActivities.clear(); + } /** * Schedule a notification call from JavaScript @@ -297,4 +386,445 @@ public static LocalNotificationsPlugin getLocalNotificationsInstance() { } return null; } + + /** + * Public method to notify listeners from external classes (like TimerEndReceiver). + * This is needed because notifyListeners is protected in the parent Plugin class. + */ + public void fireTimerEnded(String activityId) { + JSObject data = new JSObject(); + data.put("activityId", activityId); + notifyListeners("liveActivityEnded", data, true); + } + + // ============================================ + // LIVE ACTIVITY / TIMER NOTIFICATION METHODS + // ============================================ + + /** + * Start a Live Activity (on Android, this is a notification with native chronometer). + * The system handles timer display automatically - works in background. + */ + @PluginMethod + public void startLiveActivity(PluginCall call) { + String id = call.getString("id"); + String title = call.getString("title"); + String message = call.getString("message"); + String actionTypeId = call.getString("actionTypeId"); + + if (id == null || title == null) { + call.reject("id and title are required"); + return; + } + + // Generate notification ID from string ID + int notificationId = Math.abs(id.hashCode()); + + // Get channel ID, default to live_activity channel + String channelId = call.getString("channelId", LIVE_ACTIVITY_CHANNEL_ID); + + // Ensure the channel exists + createLiveActivityChannelIfNeeded(); + + // Check if initial vibration is requested + Boolean shouldVibrate = call.getBoolean("vibrate", true); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(getContext(), channelId) + .setContentTitle(title) + .setContentText(message != null ? message : "") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setOngoing(true) + .setAutoCancel(false) + .setPriority(NotificationCompat.PRIORITY_HIGH); + + // Vibrate on first show, then silent for updates + if (shouldVibrate) { + builder.setVibrate(new long[]{0, 300, 200, 300}); // Vibration pattern + builder.setDefaults(NotificationCompat.DEFAULT_LIGHTS); + } else { + builder.setOnlyAlertOnce(true); + } + + // Configure timer if present + JSObject timer = call.getObject("timer"); + boolean hasTimer = false; + long startTimestamp = System.currentTimeMillis(); + long maxDurationMs = 0; + boolean hasProgressService = false; + + if (timer != null) { + String mode = timer.getString("mode", "countdown"); + // Use optLong to avoid JSONException - returns 0 if not found + long targetTimestamp = timer.optLong("targetTimestamp", 0); + + // Get maxDuration for elapsed timers + maxDurationMs = timer.optLong("maxDurationMs", 0); + startTimestamp = timer.optLong("startTimestamp", System.currentTimeMillis()); + + if (targetTimestamp > 0) { + hasTimer = true; + builder.setUsesChronometer(true); + builder.setWhen(targetTimestamp); + builder.setShowWhen(true); + + // API 24+ for countdown mode + if ("countdown".equals(mode) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setChronometerCountDown(true); + } + + // Optional: schedule alarm for when timer ends + Boolean alertOnEnd = timer.getBoolean("alertOnEnd", false); + if (alertOnEnd) { + // Use alertTimestamp if provided (for elapsed timers with maxDuration), + // otherwise use targetTimestamp (for countdown timers) + long alertTimestamp = timer.optLong("alertTimestamp", targetTimestamp); + scheduleTimerEndAlarm(id, alertTimestamp); + } + + // For elapsed timers with maxDuration, start the background progress service + if ("elapsed".equals(mode) && maxDurationMs > 0) { + hasProgressService = true; + TimerProgressService.startTimer( + getContext(), + id, + notificationId, + title, + message, + channelId, + actionTypeId, + startTimestamp, + maxDurationMs + ); + } + } + } + + // Configure progress bar if present + JSObject progress = call.getObject("progress"); + if (progress != null) { + int max = progress.getInteger("max", 100); + int current = progress.getInteger("current", 0); + boolean indeterminate = progress.getBoolean("indeterminate", false); + builder.setProgress(max, current, indeterminate); + } + + // Use BigTextStyle to ensure message is visible even with chronometer + if (hasTimer || progress != null) { + String displayText = message != null ? message : ""; + NotificationCompat.BigTextStyle bigTextStyle = new NotificationCompat.BigTextStyle() + .bigText(displayText) + .setBigContentTitle(title); + builder.setStyle(bigTextStyle); + } + + // Add action buttons if actionTypeId is provided + if (actionTypeId != null) { + addActionsToLiveActivity(builder, id, notificationId, actionTypeId); + } + + // Add content intent (open app when notification is tapped) + addContentIntentToLiveActivity(builder, id, notificationId); + + // Store configuration for updates BEFORE showing notification + activeLiveActivities.put(id, new LiveActivityConfig( + notificationId, title, message, channelId, actionTypeId, timer, + startTimestamp, maxDurationMs, hasProgressService + )); + + // Show the notification only if the service won't handle it + // When hasProgressService is true, the foreground service shows the notification + if (!hasProgressService) { + notificationManager.notify(notificationId, builder.build()); + } + + JSObject result = new JSObject(); + result.put("activityId", id); + result.put("notificationId", notificationId); + call.resolve(result); + } + + /** + * Add action buttons to a Live Activity notification. + */ + private void addActionsToLiveActivity(NotificationCompat.Builder builder, String activityId, int notificationId, String actionTypeId) { + NotificationAction[] actionGroup = notificationStorage.getActionGroup(actionTypeId); + if (actionGroup == null) { + return; + } + + int flags = PendingIntent.FLAG_CANCEL_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = flags | PendingIntent.FLAG_MUTABLE; + } + + for (NotificationAction action : actionGroup) { + Intent actionIntent = buildLiveActivityActionIntent(activityId, notificationId, action.getId()); + PendingIntent actionPendingIntent = PendingIntent.getActivity( + getContext(), + notificationId + action.getId().hashCode(), + actionIntent, + flags + ); + + NotificationCompat.Action.Builder actionBuilder = new NotificationCompat.Action.Builder( + android.R.drawable.ic_menu_send, // Default icon + action.getTitle(), + actionPendingIntent + ); + + builder.addAction(actionBuilder.build()); + } + } + + /** + * Add content intent (tap to open) to a Live Activity notification. + */ + private void addContentIntentToLiveActivity(NotificationCompat.Builder builder, String activityId, int notificationId) { + Intent intent = buildLiveActivityActionIntent(activityId, notificationId, "tap"); + int flags = PendingIntent.FLAG_CANCEL_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = flags | PendingIntent.FLAG_MUTABLE; + } + PendingIntent pendingIntent = PendingIntent.getActivity(getContext(), notificationId, intent, flags); + builder.setContentIntent(pendingIntent); + } + + /** + * Build an intent for Live Activity actions. + */ + private Intent buildLiveActivityActionIntent(String activityId, int notificationId, String actionId) { + String packageName = getContext().getPackageName(); + Intent intent = getContext().getPackageManager().getLaunchIntentForPackage(packageName); + if (intent == null) { + intent = new Intent(); + } + intent.setAction(Intent.ACTION_MAIN); + intent.addCategory(Intent.CATEGORY_LAUNCHER); + intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.putExtra(LocalNotificationManager.NOTIFICATION_INTENT_KEY, notificationId); + intent.putExtra(LocalNotificationManager.ACTION_INTENT_KEY, actionId); + intent.putExtra("liveActivityId", activityId); + intent.putExtra(LocalNotificationManager.NOTIFICATION_IS_REMOVABLE_KEY, true); + + // Build a minimal notification JSON for the action handler + JSObject notificationObj = new JSObject(); + notificationObj.put("id", notificationId); + notificationObj.put("liveActivityId", activityId); + intent.putExtra(LocalNotificationManager.NOTIFICATION_OBJ_INTENT_KEY, notificationObj.toString()); + + return intent; + } + + /** + * Update a Live Activity content. + */ + @PluginMethod + public void updateLiveActivity(PluginCall call) { + String id = call.getString("id"); + if (id == null) { + call.reject("id is required"); + return; + } + + LiveActivityConfig config = activeLiveActivities.get(id); + if (config == null) { + call.reject("No active Live Activity with id: " + id); + return; + } + + String title = call.getString("title"); + String message = call.getString("message"); + + // Use stored config or provided values + String finalTitle = title != null ? title : config.title; + String finalMessage = message != null ? message : (config.message != null ? config.message : ""); + + // Check if vibration is requested (for alert state) + Boolean shouldVibrate = call.getBoolean("vibrate", false); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(getContext(), config.channelId) + .setContentTitle(finalTitle) + .setContentText(finalMessage) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setOngoing(true) + .setAutoCancel(false) + .setPriority(NotificationCompat.PRIORITY_HIGH); + + // Vibrate if requested (e.g., for alert state), otherwise silent + if (shouldVibrate) { + builder.setVibrate(new long[]{0, 500, 250, 500}); // Alert vibration pattern (longer) + builder.setDefaults(NotificationCompat.DEFAULT_LIGHTS); + } else { + builder.setOnlyAlertOnce(true); + } + + // Restore chronometer from config + if (config.timer != null) { + String mode = config.timer.getString("mode", "countdown"); + long targetTimestamp = config.timer.optLong("targetTimestamp", 0); + + if (targetTimestamp > 0) { + builder.setUsesChronometer(true); + builder.setWhen(targetTimestamp); + builder.setShowWhen(true); + + if ("countdown".equals(mode) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setChronometerCountDown(true); + } + } + } + + // Update progress bar if present + JSObject progress = call.getObject("progress"); + boolean hasProgress = false; + if (progress != null) { + hasProgress = true; + int max = progress.getInteger("max", 100); + int current = progress.getInteger("current", 0); + boolean indeterminate = progress.getBoolean("indeterminate", false); + builder.setProgress(max, current, indeterminate); + } + + // Use BigTextStyle to ensure content is visible + if (config.timer != null || hasProgress) { + NotificationCompat.BigTextStyle bigTextStyle = new NotificationCompat.BigTextStyle() + .bigText(finalMessage) + .setBigContentTitle(finalTitle); + builder.setStyle(bigTextStyle); + } + + // Restore action buttons from config + if (config.actionTypeId != null) { + addActionsToLiveActivity(builder, id, config.notificationId, config.actionTypeId); + } + + // Restore content intent + addContentIntentToLiveActivity(builder, id, config.notificationId); + + // Silent update + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setSilent(true); + } + + notificationManager.notify(config.notificationId, builder.build()); + call.resolve(); + } + + /** + * End/dismiss a Live Activity. + */ + @PluginMethod + public void endLiveActivity(PluginCall call) { + String id = call.getString("id"); + if (id == null) { + call.reject("id is required"); + return; + } + + LiveActivityConfig config = activeLiveActivities.get(id); + if (config != null) { + // Stop the progress service if running + if (config.hasProgressService) { + TimerProgressService.stopTimer(getContext()); + } + + // Cancel the notification + notificationManager.cancel(config.notificationId); + + // Cancel any pending alarm + cancelTimerEndAlarm(id); + + // Remove from tracking + activeLiveActivities.remove(id); + } + + call.resolve(); + } + + /** + * Get list of active Live Activities. + */ + @PluginMethod + public void getActiveLiveActivities(PluginCall call) { + JSObject result = new JSObject(); + JSArray activities = new JSArray(); + + for (String activityId : activeLiveActivities.keySet()) { + activities.put(activityId); + } + + result.put("activities", activities); + call.resolve(result); + } + + /** + * Create the Live Activity notification channel if it doesn't exist. + */ + private void createLiveActivityChannelIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + android.app.NotificationChannel channel = notificationManager.getNotificationChannel(LIVE_ACTIVITY_CHANNEL_ID); + if (channel == null) { + channel = new android.app.NotificationChannel( + LIVE_ACTIVITY_CHANNEL_ID, + "Live Activities", + NotificationManager.IMPORTANCE_LOW // Low importance = no sound + ); + channel.setDescription("Notifications for active timers and live activities"); + channel.setSound(null, null); // No sound + channel.enableVibration(false); + notificationManager.createNotificationChannel(channel); + } + } + } + + /** + * Schedule an alarm for when the timer ends. + */ + private void scheduleTimerEndAlarm(String activityId, long targetTime) { + Intent intent = new Intent(getContext(), TimerEndReceiver.class); + intent.putExtra(TimerEndReceiver.ACTIVITY_ID_KEY, activityId); + + int requestCode = Math.abs(activityId.hashCode()); + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = flags | PendingIntent.FLAG_IMMUTABLE; + } + + PendingIntent pendingIntent = PendingIntent.getBroadcast( + getContext(), requestCode, intent, flags + ); + + AlarmManager alarmManager = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); + if (alarmManager != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { + // Fallback to inexact alarm if exact alarms not allowed + alarmManager.set(AlarmManager.RTC_WAKEUP, targetTime, pendingIntent); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, targetTime, pendingIntent); + } else { + alarmManager.setExact(AlarmManager.RTC_WAKEUP, targetTime, pendingIntent); + } + } + } + + /** + * Cancel a pending timer end alarm. + */ + private void cancelTimerEndAlarm(String activityId) { + Intent intent = new Intent(getContext(), TimerEndReceiver.class); + int requestCode = Math.abs(activityId.hashCode()); + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = flags | PendingIntent.FLAG_IMMUTABLE; + } + + PendingIntent pendingIntent = PendingIntent.getBroadcast( + getContext(), requestCode, intent, flags + ); + + AlarmManager alarmManager = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); + if (alarmManager != null && pendingIntent != null) { + alarmManager.cancel(pendingIntent); + } + } } diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/TimerEndReceiver.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/TimerEndReceiver.java new file mode 100644 index 000000000..d2466b42d --- /dev/null +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/TimerEndReceiver.java @@ -0,0 +1,39 @@ +package com.capacitorjs.plugins.localnotifications; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import com.getcapacitor.Logger; + +/** + * BroadcastReceiver that handles timer end events for Live Activities. + * When a timer countdown reaches zero, this receiver is triggered by AlarmManager + * and notifies the JavaScript layer via the "liveActivityEnded" event. + * + * @since 7.1.0 + */ +public class TimerEndReceiver extends BroadcastReceiver { + + public static final String ACTIVITY_ID_KEY = "activityId"; + + @Override + public void onReceive(Context context, Intent intent) { + String activityId = intent.getStringExtra(ACTIVITY_ID_KEY); + + if (activityId == null) { + Logger.debug(Logger.tags("LN"), "TimerEndReceiver: No activity ID provided"); + return; + } + + Logger.debug(Logger.tags("LN"), "TimerEndReceiver: Timer ended for activity " + activityId); + + // Notify the JavaScript layer that timer ended + // Use the public method fireTimerEnded since notifyListeners is protected + LocalNotificationsPlugin plugin = LocalNotificationsPlugin.getLocalNotificationsInstance(); + if (plugin != null) { + plugin.fireTimerEnded(activityId); + } else { + Logger.debug(Logger.tags("LN"), "TimerEndReceiver: Plugin instance not available"); + } + } +} diff --git a/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/TimerProgressService.java b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/TimerProgressService.java new file mode 100644 index 000000000..2e1adfc90 --- /dev/null +++ b/local-notifications/android/src/main/java/com/capacitorjs/plugins/localnotifications/TimerProgressService.java @@ -0,0 +1,444 @@ +package com.capacitorjs.plugins.localnotifications; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import com.getcapacitor.Logger; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Foreground Service that handles timer progress updates for Live Activities. + * This service continues to run even when the app is closed, ensuring that: + * 1. Progress bar updates every 2 seconds + * 2. Alert state is triggered when max duration is exceeded + * 3. Vibration is triggered on alert state + * + * @since 7.1.0 + */ +public class TimerProgressService extends Service { + + private static final String TAG = "TimerProgressService"; + private static final String PREFS_NAME = "timer_progress_prefs"; + private static final long UPDATE_INTERVAL_MS = 2000; // Update every 2 seconds + + // Intent action keys + public static final String ACTION_START_TIMER = "com.capacitorjs.localnotifications.START_TIMER"; + public static final String ACTION_STOP_TIMER = "com.capacitorjs.localnotifications.STOP_TIMER"; + + // Intent extra keys + public static final String EXTRA_ACTIVITY_ID = "activityId"; + public static final String EXTRA_NOTIFICATION_ID = "notificationId"; + public static final String EXTRA_TITLE = "title"; + public static final String EXTRA_MESSAGE = "message"; + public static final String EXTRA_CHANNEL_ID = "channelId"; + public static final String EXTRA_ACTION_TYPE_ID = "actionTypeId"; + public static final String EXTRA_START_TIMESTAMP = "startTimestamp"; + public static final String EXTRA_MAX_DURATION_MS = "maxDurationMs"; + + private Handler handler; + private Runnable updateRunnable; + private NotificationManager notificationManager; + private NotificationStorage notificationStorage; + + // Current timer state + private String currentActivityId; + private int currentNotificationId; + private String currentTitle; + private String currentMessage; + private String currentChannelId; + private String currentActionTypeId; + private long startTimestamp; + private long maxDurationMs; + private boolean hasExceeded = false; + + @Override + public void onCreate() { + super.onCreate(); + Logger.debug(Logger.tags("LN"), TAG + ": Service created"); + handler = new Handler(Looper.getMainLooper()); + notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + notificationStorage = new NotificationStorage(this); + // Note: channel is created in createInitialNotification() when we have the channelId + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + Logger.debug(Logger.tags("LN"), TAG + ": Intent is null, stopping service"); + stopSelf(); + return START_NOT_STICKY; + } + + String action = intent.getAction(); + + if (ACTION_STOP_TIMER.equals(action)) { + Logger.debug(Logger.tags("LN"), TAG + ": Stopping timer"); + stopTimer(); + stopSelf(); + return START_NOT_STICKY; + } + + if (ACTION_START_TIMER.equals(action)) { + // Extract timer configuration from intent + currentActivityId = intent.getStringExtra(EXTRA_ACTIVITY_ID); + currentNotificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0); + currentTitle = intent.getStringExtra(EXTRA_TITLE); + currentMessage = intent.getStringExtra(EXTRA_MESSAGE); + currentChannelId = intent.getStringExtra(EXTRA_CHANNEL_ID); + currentActionTypeId = intent.getStringExtra(EXTRA_ACTION_TYPE_ID); + startTimestamp = intent.getLongExtra(EXTRA_START_TIMESTAMP, System.currentTimeMillis()); + maxDurationMs = intent.getLongExtra(EXTRA_MAX_DURATION_MS, 0); + hasExceeded = false; + + Logger.debug(Logger.tags("LN"), TAG + ": Starting timer for activity " + currentActivityId + + ", maxDuration=" + maxDurationMs + "ms"); + + // Save to SharedPreferences for persistence + saveTimerConfig(); + + // Start foreground service using the main timer notification + // This avoids having two separate notifications + startForeground(currentNotificationId, createInitialNotification()); + + // Start the update loop + startUpdateLoop(); + } + + return START_STICKY; + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onDestroy() { + Logger.debug(Logger.tags("LN"), TAG + ": Service destroyed"); + stopTimer(); + super.onDestroy(); + } + + private static final String DEFAULT_CHANNEL_ID = "live_activity"; + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + String channelId = currentChannelId != null ? currentChannelId : DEFAULT_CHANNEL_ID; + NotificationChannel channel = notificationManager.getNotificationChannel(channelId); + if (channel == null) { + channel = new NotificationChannel( + channelId, + "Live Activities", + NotificationManager.IMPORTANCE_HIGH + ); + channel.setDescription("Timer notifications"); + channel.enableVibration(true); + notificationManager.createNotificationChannel(channel); + } + } + } + + private Notification createInitialNotification() { + // Ensure the channel exists + createNotificationChannel(); + + String channelId = currentChannelId != null ? currentChannelId : DEFAULT_CHANNEL_ID; + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, channelId) + .setContentTitle(currentTitle) + .setContentText(currentMessage != null ? currentMessage : "") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setOngoing(true) + .setAutoCancel(false) + .setUsesChronometer(true) + .setWhen(startTimestamp) + .setShowWhen(true) + .setProgress(100, 0, false) + // Vibrer au demarrage + .setVibrate(new long[]{0, 300, 200, 300}) + .setDefaults(NotificationCompat.DEFAULT_LIGHTS); + + // Use BigTextStyle + NotificationCompat.BigTextStyle bigTextStyle = new NotificationCompat.BigTextStyle() + .bigText(currentMessage != null ? currentMessage : "") + .setBigContentTitle(currentTitle); + builder.setStyle(bigTextStyle); + + // Add action buttons + if (currentActionTypeId != null) { + addActionsToNotification(builder); + } + + // Add content intent + addContentIntent(builder); + + return builder.build(); + } + + private void saveTimerConfig() { + SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); + prefs.edit() + .putString("activityId", currentActivityId) + .putInt("notificationId", currentNotificationId) + .putString("title", currentTitle) + .putString("message", currentMessage) + .putString("channelId", currentChannelId) + .putString("actionTypeId", currentActionTypeId) + .putLong("startTimestamp", startTimestamp) + .putLong("maxDurationMs", maxDurationMs) + .putBoolean("hasExceeded", hasExceeded) + .apply(); + } + + private void clearTimerConfig() { + SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); + prefs.edit().clear().apply(); + } + + private void startUpdateLoop() { + if (updateRunnable != null) { + handler.removeCallbacks(updateRunnable); + } + + updateRunnable = new Runnable() { + @Override + public void run() { + updateProgress(); + // Continue updating as long as service is running + handler.postDelayed(this, UPDATE_INTERVAL_MS); + } + }; + + // Start immediately + handler.post(updateRunnable); + } + + private void stopTimer() { + if (updateRunnable != null) { + handler.removeCallbacks(updateRunnable); + updateRunnable = null; + } + clearTimerConfig(); + } + + private void updateProgress() { + if (currentActivityId == null || maxDurationMs <= 0) { + return; + } + + long now = System.currentTimeMillis(); + long elapsedMs = now - startTimestamp; + float progressPercent = Math.min((float) elapsedMs / maxDurationMs * 100, 100); + boolean isExceeded = elapsedMs >= maxDurationMs; + + // Check if we just exceeded + if (isExceeded && !hasExceeded) { + hasExceeded = true; + saveTimerConfig(); + Logger.debug(Logger.tags("LN"), TAG + ": Timer exceeded! Updating to alert state"); + updateNotificationToAlert(); + + // Notify JavaScript layer + LocalNotificationsPlugin plugin = LocalNotificationsPlugin.getLocalNotificationsInstance(); + if (plugin != null) { + plugin.fireTimerEnded(currentActivityId); + } + return; + } + + // If already exceeded, don't update progress anymore + if (hasExceeded) { + return; + } + + // Update the notification with new progress + updateNotificationProgress((int) progressPercent); + } + + private void updateNotificationProgress(int progressPercent) { + String channelId = currentChannelId != null ? currentChannelId : DEFAULT_CHANNEL_ID; + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, channelId) + .setContentTitle(currentTitle) + .setContentText(currentMessage != null ? currentMessage : "") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setOngoing(true) + .setAutoCancel(false) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setOnlyAlertOnce(true) // Don't vibrate on updates + .setUsesChronometer(true) + .setWhen(startTimestamp) + .setShowWhen(true) + .setProgress(100, progressPercent, false); + + // Use BigTextStyle + NotificationCompat.BigTextStyle bigTextStyle = new NotificationCompat.BigTextStyle() + .bigText(currentMessage != null ? currentMessage : "") + .setBigContentTitle(currentTitle); + builder.setStyle(bigTextStyle); + + // Add action buttons + if (currentActionTypeId != null) { + addActionsToNotification(builder); + } + + // Add content intent + addContentIntent(builder); + + notificationManager.notify(currentNotificationId, builder.build()); + } + + private void updateNotificationToAlert() { + String alertTitle = "⚠️ DURÉE DÉPASSÉE"; + String alertMessage = "Temps de stationnement dépassé"; + + String channelId = currentChannelId != null ? currentChannelId : DEFAULT_CHANNEL_ID; + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, channelId) + .setContentTitle(alertTitle) + .setContentText(alertMessage) + .setSmallIcon(android.R.drawable.ic_dialog_alert) + .setOngoing(true) + .setAutoCancel(false) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setUsesChronometer(true) + .setWhen(startTimestamp) + .setShowWhen(true) + .setProgress(100, 100, false) + // Important: NE PAS utiliser setOnlyAlertOnce pour permettre la vibration + // Vibration pour l'alerte + .setVibrate(new long[]{0, 500, 250, 500, 250, 500}) + .setDefaults(NotificationCompat.DEFAULT_VIBRATE | NotificationCompat.DEFAULT_LIGHTS | NotificationCompat.DEFAULT_SOUND); + + // Use BigTextStyle with alert styling + NotificationCompat.BigTextStyle bigTextStyle = new NotificationCompat.BigTextStyle() + .bigText(alertMessage) + .setBigContentTitle(alertTitle); + builder.setStyle(bigTextStyle); + + // Add action buttons + if (currentActionTypeId != null) { + addActionsToNotification(builder); + } + + // Add content intent + addContentIntent(builder); + + notificationManager.notify(currentNotificationId, builder.build()); + } + + private void addActionsToNotification(NotificationCompat.Builder builder) { + NotificationAction[] actionGroup = notificationStorage.getActionGroup(currentActionTypeId); + if (actionGroup == null) { + return; + } + + int flags = PendingIntent.FLAG_CANCEL_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = flags | PendingIntent.FLAG_MUTABLE; + } + + for (NotificationAction action : actionGroup) { + Intent actionIntent = buildActionIntent(action.getId()); + PendingIntent actionPendingIntent = PendingIntent.getActivity( + this, + currentNotificationId + action.getId().hashCode(), + actionIntent, + flags + ); + + NotificationCompat.Action.Builder actionBuilder = new NotificationCompat.Action.Builder( + android.R.drawable.ic_menu_send, + action.getTitle(), + actionPendingIntent + ); + + builder.addAction(actionBuilder.build()); + } + } + + private Intent buildActionIntent(String actionId) { + String packageName = getPackageName(); + Intent intent = getPackageManager().getLaunchIntentForPackage(packageName); + if (intent == null) { + intent = new Intent(); + intent.setPackage(packageName); + } + intent.setAction(Intent.ACTION_MAIN); + intent.addCategory(Intent.CATEGORY_LAUNCHER); + // FLAG_ACTIVITY_NEW_TASK is required when starting from a service + // FLAG_ACTIVITY_SINGLE_TOP prevents creating a new activity if one exists + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.putExtra(LocalNotificationManager.NOTIFICATION_INTENT_KEY, currentNotificationId); + intent.putExtra(LocalNotificationManager.ACTION_INTENT_KEY, actionId); + intent.putExtra("liveActivityId", currentActivityId); + intent.putExtra(LocalNotificationManager.NOTIFICATION_IS_REMOVABLE_KEY, true); + + // Build notification JSON for action handler + JSONObject notificationObj = new JSONObject(); + try { + notificationObj.put("id", currentNotificationId); + notificationObj.put("liveActivityId", currentActivityId); + notificationObj.put("actionId", actionId); + } catch (JSONException e) { + Logger.error(Logger.tags("LN"), "Error building notification JSON", e); + } + intent.putExtra(LocalNotificationManager.NOTIFICATION_OBJ_INTENT_KEY, notificationObj.toString()); + + return intent; + } + + private void addContentIntent(NotificationCompat.Builder builder) { + Intent intent = buildActionIntent("tap"); + int flags = PendingIntent.FLAG_CANCEL_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = flags | PendingIntent.FLAG_MUTABLE; + } + PendingIntent pendingIntent = PendingIntent.getActivity(this, currentNotificationId, intent, flags); + builder.setContentIntent(pendingIntent); + } + + /** + * Static method to start the timer progress service. + */ + public static void startTimer(Context context, String activityId, int notificationId, + String title, String message, String channelId, String actionTypeId, + long startTimestamp, long maxDurationMs) { + Intent intent = new Intent(context, TimerProgressService.class); + intent.setAction(ACTION_START_TIMER); + intent.putExtra(EXTRA_ACTIVITY_ID, activityId); + intent.putExtra(EXTRA_NOTIFICATION_ID, notificationId); + intent.putExtra(EXTRA_TITLE, title); + intent.putExtra(EXTRA_MESSAGE, message); + intent.putExtra(EXTRA_CHANNEL_ID, channelId); + intent.putExtra(EXTRA_ACTION_TYPE_ID, actionTypeId); + intent.putExtra(EXTRA_START_TIMESTAMP, startTimestamp); + intent.putExtra(EXTRA_MAX_DURATION_MS, maxDurationMs); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } + } + + /** + * Static method to stop the timer progress service. + */ + public static void stopTimer(Context context) { + Intent intent = new Intent(context, TimerProgressService.class); + intent.setAction(ACTION_STOP_TIMER); + context.startService(intent); + } +} diff --git a/local-notifications/ios/Sources/LocalNotificationsPlugin/LocalNotificationsHandler.swift b/local-notifications/ios/Sources/LocalNotificationsPlugin/LocalNotificationsHandler.swift index e0bf257f2..f7335a6bc 100644 --- a/local-notifications/ios/Sources/LocalNotificationsPlugin/LocalNotificationsHandler.swift +++ b/local-notifications/ios/Sources/LocalNotificationsPlugin/LocalNotificationsHandler.swift @@ -35,11 +35,20 @@ public class LocalNotificationsHandler: NSObject, NotificationHandlerProtocol { } } - return [ - .badge, - .sound, - .alert - ] + if #available(iOS 14.0, *) { + return [ + .badge, + .sound, + .banner, + .list + ] + } else { + return [ + .badge, + .sound, + .alert + ] + } } public func didReceive(response: UNNotificationResponse) { diff --git a/local-notifications/ios/Sources/LocalNotificationsPlugin/LocalNotificationsPlugin.swift b/local-notifications/ios/Sources/LocalNotificationsPlugin/LocalNotificationsPlugin.swift index 463be9a12..d48ebb369 100644 --- a/local-notifications/ios/Sources/LocalNotificationsPlugin/LocalNotificationsPlugin.swift +++ b/local-notifications/ios/Sources/LocalNotificationsPlugin/LocalNotificationsPlugin.swift @@ -1,6 +1,7 @@ import Foundation import Capacitor import UserNotifications +import ActivityKit enum LocalNotificationError: LocalizedError { case contentNoId @@ -43,7 +44,12 @@ public class LocalNotificationsPlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "removeDeliveredNotifications", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "createChannel", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "deleteChannel", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "listChannels", returnType: CAPPluginReturnPromise) + CAPPluginMethod(name: "listChannels", returnType: CAPPluginReturnPromise), + // Live Activity methods + CAPPluginMethod(name: "startLiveActivity", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "updateLiveActivity", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "endLiveActivity", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getActiveLiveActivities", returnType: CAPPluginReturnPromise) ] private let notificationDelegationHandler = LocalNotificationsHandler() @@ -256,7 +262,12 @@ public class LocalNotificationsPlugin: CAPPlugin, CAPBridgedPlugin { } if let summaryArgument = notification["summaryArgument"] as? String { - content.summaryArgument = summaryArgument + if #available(iOS 15.0, *) { + // summaryArgument is deprecated in iOS 15.0 and ignored + // Use threadIdentifier for grouping instead + } else { + content.summaryArgument = summaryArgument + } } if let sound = notification["sound"] as? String { @@ -627,4 +638,266 @@ public class LocalNotificationsPlugin: CAPPlugin, CAPBridgedPlugin { @objc func listChannels(_ call: CAPPluginCall) { call.unimplemented() } + + // ============================================ + // LIVE ACTIVITY METHODS + // ============================================ + + /** + * Start a Live Activity. + * Uses ActivityKit on iOS 16.1+, falls back to regular notification on older versions. + */ + @objc func startLiveActivity(_ call: CAPPluginCall) { + CAPLog.print("[LN] ⚡️ startLiveActivity called") + + guard let id = call.getString("id") else { + CAPLog.print("[LN] ❌ id is required") + call.reject("id is required") + return + } + guard let title = call.getString("title") else { + CAPLog.print("[LN] ❌ title is required") + call.reject("title is required") + return + } + let message = call.getString("message") ?? "" + + CAPLog.print("[LN] 📋 Live Activity params - id: \(id), title: \(title), message: \(message)") + + // Check if Live Activities are available + // Get actionTypeId + let actionTypeId = call.getString("actionTypeId") + + if #available(iOS 16.1, *) { + CAPLog.print("[LN] ✅ iOS 16.1+ detected") + + // Check if Live Activities are enabled + let authInfo = ActivityAuthorizationInfo() + CAPLog.print("[LN] 📱 Activities enabled: \(authInfo.areActivitiesEnabled)") + + guard authInfo.areActivitiesEnabled else { + CAPLog.print("[LN] ⚠️ Live Activities disabled, falling back to regular notification") + // Fall back to regular notification + scheduleRegularNotification(id: id, title: title, message: message, actionTypeId: actionTypeId, call: call) + return + } + + // Get timer configuration + var timerMode: String = "countdown" + var timerTargetTimestamp: Double? + + if let timer = call.getObject("timer") { + timerMode = timer["mode"] as? String ?? "countdown" + timerTargetTimestamp = timer["targetTimestamp"] as? Double + } + + // Get content state + var contentState = call.getObject("contentState") ?? [:] + let staticAttributes = call.getObject("staticAttributes") ?? [:] + + // Get actionTypeId for button display in widget + if let actionTypeId = call.getString("actionTypeId") { + contentState["actionTypeId"] = actionTypeId + } + + // Create the activity with GenericTimerAttributes + CAPLog.print("[LN] 🚀 Creating Live Activity with timerMode: \(timerMode), targetTimestamp: \(String(describing: timerTargetTimestamp))") + + do { + let attributes = GenericTimerAttributes( + id: id, + title: title, + staticValues: staticAttributes.compactMapValues { "\($0)" } + ) + + let initialState = GenericTimerAttributes.TimerContentState( + message: message, + values: contentState.compactMapValues { "\($0)" }, + timerMode: timerMode, + timerTargetTimestamp: timerTargetTimestamp + ) + + CAPLog.print("[LN] 📝 Requesting activity...") + let activity = try Activity.request( + attributes: attributes, + content: .init(state: initialState, staleDate: nil), + pushType: nil + ) + + CAPLog.print("[LN] ✅ Live Activity created successfully with id: \(activity.id)") + call.resolve([ + "activityId": activity.id + ]) + } catch { + CAPLog.print("[LN] ❌ Failed to start Live Activity: \(error)") + CAPLog.print("[LN] ⚠️ Falling back to regular notification") + // Fall back to regular notification + scheduleRegularNotification(id: id, title: title, message: message, actionTypeId: actionTypeId, call: call) + } + } else { + CAPLog.print("[LN] ⚠️ iOS < 16.1 detected, falling back to regular notification") + // iOS < 16.1: Fall back to regular notification + scheduleRegularNotification(id: id, title: title, message: message, actionTypeId: actionTypeId, call: call) + } + } + + /** + * Update a Live Activity. + */ + @objc func updateLiveActivity(_ call: CAPPluginCall) { + guard let id = call.getString("id") else { + call.reject("id is required") + return + } + + if #available(iOS 16.1, *) { + let message = call.getString("message") + let contentState = call.getObject("contentState") ?? [:] + + Task { + // Find the activity with matching ID + for activity in Activity.activities { + if activity.attributes.id == id { + // Merge new values with existing values (instead of replacing) + var mergedValues = activity.content.state.values + for (key, value) in contentState.compactMapValues({ "\($0)" }) { + mergedValues[key] = value + } + + let updatedState = GenericTimerAttributes.TimerContentState( + message: message ?? activity.content.state.message, + values: mergedValues, + timerMode: activity.content.state.timerMode, + timerTargetTimestamp: activity.content.state.timerTargetTimestamp + ) + + await activity.update(ActivityContent(state: updatedState, staleDate: nil)) + call.resolve() + return + } + } + call.reject("No active Live Activity with id: \(id)") + } + } else { + call.reject("Live Activities require iOS 16.1+") + } + } + + /** + * End a Live Activity. + */ + @objc func endLiveActivity(_ call: CAPPluginCall) { + guard let id = call.getString("id") else { + call.reject("id is required") + return + } + + if #available(iOS 16.1, *) { + Task { + for activity in Activity.activities { + if activity.attributes.id == id { + await activity.end(nil, dismissalPolicy: .immediate) + call.resolve() + return + } + } + // Also try to cancel as regular notification + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id]) + call.resolve() + } + } else { + // Cancel regular notification + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id]) + call.resolve() + } + } + + /** + * Get list of active Live Activities. + */ + @objc func getActiveLiveActivities(_ call: CAPPluginCall) { + if #available(iOS 16.1, *) { + var activityIds: [String] = [] + for activity in Activity.activities { + activityIds.append(activity.attributes.id) + } + call.resolve([ + "activities": activityIds + ]) + } else { + call.resolve([ + "activities": [] + ]) + } + } + + /** + * Schedule a regular notification as fallback when Live Activities are not available. + */ + private func scheduleRegularNotification(id: String, title: String, message: String, actionTypeId: String?, call: CAPPluginCall) { + let content = UNMutableNotificationContent() + content.title = title + content.body = message + + // Set action type if provided + if let actionTypeId = actionTypeId { + content.categoryIdentifier = actionTypeId + } + + // Add liveActivityId to extra for action handling + content.userInfo = [ + "cap_extra": [ + "liveActivityId": id + ] + ] + + let request = UNNotificationRequest(identifier: id, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + call.reject("Failed to schedule notification: \(error.localizedDescription)") + } else { + call.resolve([ + "activityId": id, + "fallback": true + ]) + } + } + } +} + +// ============================================ +// GENERIC TIMER ATTRIBUTES FOR LIVE ACTIVITIES +// ============================================ + +@available(iOS 16.1, *) +public struct GenericTimerAttributes: ActivityAttributes { + public typealias ContentState = TimerContentState + + // Static attributes (set once) + public var id: String + public var title: String + public var staticValues: [String: String] + + public init(id: String, title: String, staticValues: [String: String] = [:]) { + self.id = id + self.title = title + self.staticValues = staticValues + } + + public struct TimerContentState: Codable, Hashable { + // Dynamic values + public var message: String + public var values: [String: String] + + // Timer configuration + public var timerMode: String? + public var timerTargetTimestamp: Double? + + public init(message: String, values: [String: String] = [:], timerMode: String? = nil, timerTargetTimestamp: Double? = nil) { + self.message = message + self.values = values + self.timerMode = timerMode + self.timerTargetTimestamp = timerTargetTimestamp + } + } } diff --git a/local-notifications/package.json b/local-notifications/package.json index 31d7265fb..595aae67a 100644 --- a/local-notifications/package.json +++ b/local-notifications/package.json @@ -1,6 +1,6 @@ { - "name": "@capacitor/local-notifications", - "version": "7.0.4", + "name": "@numeriz/capacitor-local-notifications", + "version": "7.0.4-fork.1", "description": "The Local Notifications API provides a way to schedule device notifications locally (i.e. without a server sending push notifications).", "main": "dist/plugin.cjs.js", "module": "dist/esm/index.js", @@ -61,7 +61,7 @@ "rimraf": "^6.0.1", "rollup": "^4.26.0", "swiftlint": "^1.0.1", - "typescript": "~4.1.5" + "typescript": "^5.0.4" }, "peerDependencies": { "@capacitor/core": ">=7.0.0" diff --git a/local-notifications/src/definitions.ts b/local-notifications/src/definitions.ts index 2949f1068..9982e8f0a 100644 --- a/local-notifications/src/definitions.ts +++ b/local-notifications/src/definitions.ts @@ -199,12 +199,60 @@ export interface LocalNotificationsPlugin { listenerFunc: (notificationAction: ActionPerformed) => void, ): Promise; + /** + * Listen for when a Live Activity timer ends. + * + * @since 7.1.0 + */ + addListener( + eventName: 'liveActivityEnded', + listenerFunc: (event: LiveActivityEndedEvent) => void, + ): Promise; + /** * Remove all listeners for this plugin. * * @since 1.0.0 */ removeAllListeners(): Promise; + + // ============================================ + // LIVE ACTIVITIES / TIMER NOTIFICATIONS + // ============================================ + + /** + * Start a Live Activity (iOS) or Timer Notification (Android). + * Uses native system APIs for timer display - works in background. + * + * On Android: Uses setUsesChronometer() for native timer display. + * On iOS: Uses ActivityKit with Text(timerInterval:) for native timer. + * + * @since 7.1.0 + */ + startLiveActivity(options: LiveActivityOptions): Promise; + + /** + * Update a Live Activity content. + * On iOS: Updates the Live Activity content state. + * On Android: Updates the notification title/body (timer continues automatically). + * + * @since 7.1.0 + */ + updateLiveActivity(options: UpdateLiveActivityOptions): Promise; + + /** + * End/dismiss a Live Activity or Timer Notification. + * + * @since 7.1.0 + */ + endLiveActivity(options: EndLiveActivityOptions): Promise; + + /** + * Get list of active Live Activities/Timer Notifications. + * + * @since 7.1.0 + */ + getActiveLiveActivities(): Promise; } /** @@ -574,6 +622,38 @@ export interface LocalNotificationSchema { */ id: number; + /** + * Display a native chronometer in the notification. + * The system handles the timer display automatically. + * Only available for Android. + * + * @since 7.1.0 + */ + chronometer?: { + /** + * Enable chronometer display. + */ + enabled: boolean; + /** + * Count down instead of up. Requires API 24+. + */ + countDown?: boolean; + /** + * Target timestamp for the chronometer. + * For countdown: the end time. For count-up: the start time. + */ + when?: number; + }; + + /** + * If true, updates to this notification will not make sound or vibrate. + * Useful for ongoing notifications that update frequently. + * Only available for Android. + * + * @since 7.1.0 + */ + silentUpdate?: boolean; + /** * Schedule this notification for a later time. * @@ -1147,80 +1227,249 @@ export type Importance = 1 | 2 | 3 | 4 | 5; */ export type Visibility = -1 | 0 | 1; -/** - * @deprecated Use 'Channel`. - * @since 1.0.0 - */ -export type NotificationChannel = Channel; +// ============================================ +// LIVE ACTIVITIES / TIMER NOTIFICATIONS +// ============================================ /** - * @deprecated Use `LocalNotificationDescriptor`. - * @since 1.0.0 + * Options for starting a Live Activity (iOS) or Timer Notification (Android). + * + * @since 7.1.0 */ -export type LocalNotificationRequest = LocalNotificationDescriptor; +export interface LiveActivityOptions { + /** + * Unique identifier for the activity. + */ + id: string; -/** - * @deprecated Use `ScheduleResult`. - * @since 1.0.0 - */ -export type LocalNotificationScheduleResult = ScheduleResult; + /** + * Display title. + */ + title: string; -/** - * @deprecated Use `PendingResult`. - * @since 1.0.0 - */ -export type LocalNotificationPendingList = PendingResult; + /** + * Display message/body. + */ + message: string; -/** - * @deprecated Use `ActionType`. - * @since 1.0.0 - */ -export type LocalNotificationActionType = ActionType; + /** + * Timer configuration (optional). + * If provided, the system handles the timer display automatically. + */ + timer?: { + /** + * Timer mode: countdown (toward 0) or elapsed (from 0). + * @default "countdown" + */ + mode: 'countdown' | 'elapsed'; -/** - * @deprecated Use `Action`. - * @since 1.0.0 - */ -export type LocalNotificationAction = Action; + /** + * For countdown: target end timestamp (ms since epoch). + * For elapsed: start timestamp (ms since epoch). + */ + targetTimestamp: number; -/** - * @deprecated Use `EnabledResult`. - * @since 1.0.0 - */ -export type LocalNotificationEnabledResult = EnabledResult; + /** + * If true, triggers a callback when timer ends. + * Uses AlarmManager on Android. + * @default false + */ + alertOnEnd?: boolean; -/** - * @deprecated Use `ListChannelsResult`. - * @since 1.0.0 - */ -export type NotificationChannelList = ListChannelsResult; + /** + * Timestamp (ms since epoch) when to trigger the alert. + * Required when alertOnEnd is true for elapsed timers. + * For countdown timers, defaults to targetTimestamp. + */ + alertTimestamp?: number; + + /** + * Start timestamp for elapsed timers (ms since epoch). + * Used by Android background service for progress calculation. + */ + startTimestamp?: number; + + /** + * Max duration in milliseconds for elapsed timers. + * Used by Android background service to detect exceeded state. + */ + maxDurationMs?: number; + }; + + /** + * Dynamic values (updated via updateLiveActivity). + * All values must be strings for iOS compatibility. + */ + contentState?: Record; + + /** + * Static values (set once at start, cannot be updated). + */ + staticAttributes?: Record; + + /** + * Channel ID for Android notifications. + */ + channelId?: string; + + /** + * Small icon for Android notifications. + */ + smallIcon?: string; + + /** + * Action type ID for notification actions. + * Links to registered action types (buttons on the notification). + * + * @since 7.2.0 + */ + actionTypeId?: string; + + /** + * Progress bar configuration (Android only). + * Shows a progress indicator in the notification. + * + * @since 7.2.0 + */ + progress?: { + /** + * Maximum value for the progress bar. + * @default 100 + */ + max?: number; + + /** + * Current progress value. + * @default 0 + */ + current: number; + + /** + * Whether to show an indeterminate progress bar. + * @default false + */ + indeterminate?: boolean; + }; + + /** + * Whether to vibrate when the notification is shown (Android only). + * Useful for initial notification or alert state. + * @default true + * + * @since 7.2.0 + */ + vibrate?: boolean; +} /** - * @deprecated Use `Attachment`. - * @since 1.0.0 + * Result from starting a Live Activity. + * + * @since 7.1.0 */ -export type LocalNotificationAttachment = Attachment; +export interface LiveActivityResult { + /** + * The activity identifier. + */ + activityId: string; + + /** + * Notification ID for Android (for internal use). + */ + notificationId?: number; +} /** - * @deprecated Use `AttachmentOptions`. - * @since 1.0.0 + * Options for updating a Live Activity. + * + * @since 7.1.0 */ -export type LocalNotificationAttachmentOptions = AttachmentOptions; +export interface UpdateLiveActivityOptions { + /** + * The activity identifier. + */ + id: string; + + /** + * Updated title (optional). + */ + title?: string; + + /** + * Updated message (optional). + */ + message?: string; + + /** + * Updated content state (optional). + */ + contentState?: Record; + + /** + * Updated progress bar configuration (Android only). + * + * @since 7.2.0 + */ + progress?: { + /** + * Maximum value for the progress bar. + * @default 100 + */ + max?: number; + + /** + * Current progress value. + */ + current: number; + + /** + * Whether to show an indeterminate progress bar. + * @default false + */ + indeterminate?: boolean; + }; + + /** + * Whether to vibrate when updating (Android only). + * Useful for transitioning to alert state. + * @default false + * + * @since 7.2.0 + */ + vibrate?: boolean; +} /** - * @deprecated Use `LocalNotificationSchema`. - * @since 1.0.0 + * Options for ending a Live Activity. + * + * @since 7.1.0 */ -export type LocalNotification = LocalNotificationSchema; +export interface EndLiveActivityOptions { + /** + * The activity identifier. + */ + id: string; +} /** - * @deprecated Use `Schedule`. - * @since 1.0.0 + * Result from getting active Live Activities. + * + * @since 7.1.0 */ -export type LocalNotificationSchedule = Schedule; +export interface ActiveLiveActivitiesResult { + /** + * List of active activity IDs. + */ + activities: string[]; +} /** - * @deprecated Use `ActionPerformed`. - * @since 1.0.0 + * Event fired when a Live Activity timer ends. + * + * @since 7.1.0 */ -export type LocalNotificationActionPerformed = ActionPerformed; +export interface LiveActivityEndedEvent { + /** + * The activity identifier that ended. + */ + activityId: string; +} diff --git a/local-notifications/src/web.ts b/local-notifications/src/web.ts index 97b56cf7c..68f46bc89 100644 --- a/local-notifications/src/web.ts +++ b/local-notifications/src/web.ts @@ -2,9 +2,13 @@ import { WebPlugin } from '@capacitor/core'; import type { PermissionState } from '@capacitor/core'; import type { + ActiveLiveActivitiesResult, DeliveredNotifications, EnabledResult, + EndLiveActivityOptions, ListChannelsResult, + LiveActivityOptions, + LiveActivityResult, LocalNotificationSchema, LocalNotificationsPlugin, PendingResult, @@ -12,6 +16,7 @@ import type { ScheduleOptions, ScheduleResult, SettingsPermissionStatus, + UpdateLiveActivityOptions, } from './definitions'; export class LocalNotificationsWeb @@ -149,8 +154,8 @@ export class LocalNotificationsWeb // otherwise this sends a real notification on supported browsers try { new Notification(''); - } catch (e) { - if (e.name == 'TypeError') { + } catch (e: unknown) { + if (e instanceof Error && e.name == 'TypeError') { return false; } } @@ -245,4 +250,42 @@ export class LocalNotificationsWeb protected onShow(notification: LocalNotificationSchema): void { this.notifyListeners('localNotificationReceived', notification); } + + // ============================================ + // LIVE ACTIVITIES (not supported on web) + // ============================================ + + async startLiveActivity(options: LiveActivityOptions): Promise { + // On web, fall back to a regular notification + console.warn('[LocalNotifications] Live Activities not supported on web, using regular notification'); + + const notification = new Notification(options.title, { + body: options.message, + tag: options.id, + }); + this.deliveredNotifications.push(notification); + + return { + activityId: options.id, + }; + } + + async updateLiveActivity(_options: UpdateLiveActivityOptions): Promise { + console.warn('[LocalNotifications] Live Activities not supported on web'); + } + + async endLiveActivity(options: EndLiveActivityOptions): Promise { + // Try to close the notification with this tag + const found = this.deliveredNotifications.find(n => n.tag === options.id); + if (found) { + found.close(); + this.deliveredNotifications = this.deliveredNotifications.filter(n => n !== found); + } + } + + async getActiveLiveActivities(): Promise { + return { + activities: [], + }; + } } diff --git a/local-notifications/tsconfig.json b/local-notifications/tsconfig.json index f2e88e6a0..621295a38 100644 --- a/local-notifications/tsconfig.json +++ b/local-notifications/tsconfig.json @@ -14,7 +14,8 @@ "pretty": true, "sourceMap": true, "strict": true, - "target": "es2017" + "target": "es2017", + "skipLibCheck": true }, "files": ["src/index.ts"] }