From 6f95fd307f2fbcb5f63bbcad86eeb98ea6a6d117 Mon Sep 17 00:00:00 2001 From: Daniel Gablyasz Date: Thu, 24 Sep 2026 08:46:45 +0200 Subject: [PATCH] feat(push-notifications): add app and channel enabled checks --- push-notifications/README.md | 56 ++++++- .../NotificationSettingsTest.java | 149 ++++++++++++++++++ .../NotificationChannelManager.java | 18 +++ .../PushNotificationsPlugin.java | 13 ++ .../PushNotificationsHandler.swift | 13 ++ .../PushNotificationsPlugin.swift | 12 ++ .../PushNotificationsPluginTests.swift | 55 +++++++ push-notifications/src/definitions.ts | 47 +++++- 8 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 push-notifications/android/src/androidTest/java/com/capacitorjs/plugins/pushnotifications/NotificationSettingsTest.java diff --git a/push-notifications/README.md b/push-notifications/README.md index 41adf33e75..b1e92e071b 100644 --- a/push-notifications/README.md +++ b/push-notifications/README.md @@ -195,6 +195,8 @@ const getDeliveredNotifications = async () => { * [`createChannel(...)`](#createchannel) * [`deleteChannel(...)`](#deletechannel) * [`listChannels()`](#listchannels) +* [`checkAppEnabled()`](#checkappenabled) +* [`checkChannelEnabled(...)`](#checkchannelenabled) * [`checkPermissions()`](#checkpermissions) * [`requestPermissions()`](#requestpermissions) * [`addListener('registration', ...)`](#addlistenerregistration-) @@ -342,6 +344,50 @@ Only available on Android O or newer (SDK 26+). -------------------- +### checkAppEnabled() + +```typescript +checkAppEnabled() => Promise +``` + +Check whether notifications are enabled for the app in the system settings. + +On iOS, authorized, provisional, and ephemeral authorization are considered enabled. +This does not request permission or check push registration, individual channels, +or temporary restrictions such as Do Not Disturb. + +**Returns:** Promise<EnabledResult> + +**Since:** 8.2.0 + +-------------------- + + +### checkChannelEnabled(...) + +```typescript +checkChannelEnabled(args: { id: string; }) => Promise +``` + +Check whether a notification channel exists and its importance is not `0` (disabled). + +Returns `false` if the channel does not exist. This does not check app-wide +notification settings or channel group settings. Use `checkAppEnabled()` to +check the app-wide setting separately. + +Only available on Android O or newer (SDK 26+). + +| Param | Type | +| ---------- | ---------------------------- | +| **`args`** | { id: string; } | + +**Returns:** Promise<EnabledResult> + +**Since:** 8.2.0 + +-------------------- + + ### checkPermissions() ```typescript @@ -352,7 +398,7 @@ Check permission to receive push notifications. On Android 12 and below the status is always granted because you can always receive push notifications. If you need to check if the user allows -to display notifications, use local-notifications plugin. +to display notifications, use `checkAppEnabled()`. **Returns:** Promise<PermissionStatus> @@ -518,6 +564,7 @@ Remove all native listeners for this plugin. | **`description`** | string | The description of this channel (presented to the user). | | 1.0.0 | | **`sound`** | string | The sound that should be played for notifications posted to this channel. Notification channels with an importance of at least `3` should have a sound. The file name of a sound file should be specified relative to the android app `res/raw` directory. | | 1.0.0 | | **`importance`** | Importance | The level of interruption for notifications posted to this channel. | `3` | 1.0.0 | +| **`enabled`** | boolean | Whether the channel's importance is not `0` (disabled). Returned by `listChannels()` and ignored by `createChannel()`. This does not include app-wide notification settings or channel group settings. Only available on Android O or newer (SDK 26+). | | 8.2.0 | | **`visibility`** | Visibility | The visibility of notifications posted to this channel. This setting is for whether notifications posted to this channel appear on the lockscreen or not, and if so, whether they appear in a redacted form. | | 1.0.0 | | **`lights`** | boolean | Whether notifications posted to this channel should display notification lights, on devices that support it. | | 1.0.0 | | **`lightColor`** | string | The light color for notifications posted to this channel. Only supported if lights are enabled on this channel and the device supports it. Supported color formats are `#RRGGBB` and `#RRGGBBAA`. | | 1.0.0 | @@ -531,6 +578,13 @@ Remove all native listeners for this plugin. | **`channels`** | Channel[] | List of all the Channels created by your app. | 1.0.0 | +#### EnabledResult + +| Prop | Type | Description | Since | +| ----------- | -------------------- | ----------------------------------------------------------------------- | ----- | +| **`value`** | boolean | Whether notifications are enabled for the app or the specified channel. | 8.2.0 | + + #### PermissionStatus | Prop | Type | Description | Since | diff --git a/push-notifications/android/src/androidTest/java/com/capacitorjs/plugins/pushnotifications/NotificationSettingsTest.java b/push-notifications/android/src/androidTest/java/com/capacitorjs/plugins/pushnotifications/NotificationSettingsTest.java new file mode 100644 index 0000000000..03dffe2e52 --- /dev/null +++ b/push-notifications/android/src/androidTest/java/com/capacitorjs/plugins/pushnotifications/NotificationSettingsTest.java @@ -0,0 +1,149 @@ +package com.capacitorjs.plugins.pushnotifications; + +import static org.junit.Assert.*; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SdkSuppress; +import androidx.test.platform.app.InstrumentationRegistry; +import com.getcapacitor.JSObject; +import com.getcapacitor.PluginCall; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class NotificationSettingsTest { + + private Context context; + private NotificationManager notificationManager; + private NotificationChannelManager channelManager; + private final List channelIds = new ArrayList<>(); + + @Before + public void setUp() { + context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + channelManager = new NotificationChannelManager(context, notificationManager, null); + } + + @After + public void tearDown() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + for (String id : channelIds) { + notificationManager.deleteNotificationChannel(id); + } + } + } + + @Test + public void checkAppEnabledUsesSystemSettingsWithoutRegistration() throws Exception { + PushNotificationsPlugin plugin = new PushNotificationsPlugin() { + @Override + public Context getContext() { + return context; + } + }; + RecordingCall call = new RecordingCall("checkAppEnabled", new JSObject()); + + plugin.checkAppEnabled(call); + + assertNotNull(call.result); + assertEquals(notificationManager.areNotificationsEnabled(), call.result.getBoolean("value")); + } + + @Test + @SdkSuppress(minSdkVersion = 26) + public void channelChecksAndListingAgreeForEveryImportance() throws Exception { + int[] importanceLevels = { + NotificationManager.IMPORTANCE_NONE, + NotificationManager.IMPORTANCE_MIN, + NotificationManager.IMPORTANCE_LOW, + NotificationManager.IMPORTANCE_DEFAULT, + NotificationManager.IMPORTANCE_HIGH + }; + for (int importance : importanceLevels) { + String id = UUID.randomUUID().toString(); + channelIds.add(id); + notificationManager.createNotificationChannel(new NotificationChannel(id, "Test channel", importance)); + RecordingCall check = new RecordingCall("checkChannelEnabled", new JSObject().put("id", id)); + channelManager.checkChannelEnabled(check); + assertNotNull(check.result); + assertEquals(importance != NotificationManager.IMPORTANCE_NONE, check.result.getBoolean("value")); + } + + RecordingCall list = new RecordingCall("listChannels", new JSObject()); + channelManager.listChannels(list); + assertNotNull(list.result); + JSONArray channels = list.result.getJSONArray("channels"); + int found = 0; + for (int index = 0; index < channels.length(); index++) { + JSONObject channel = channels.getJSONObject(index); + if (channelIds.contains(channel.getString("id"))) { + assertEquals(channel.getInt("importance") != NotificationManager.IMPORTANCE_NONE, channel.getBoolean("enabled")); + found++; + } + } + assertEquals(importanceLevels.length, found); + } + + @Test + @SdkSuppress(minSdkVersion = 26) + public void missingChannelIsDisabled() throws Exception { + RecordingCall call = new RecordingCall("checkChannelEnabled", new JSObject().put("id", UUID.randomUUID().toString())); + channelManager.checkChannelEnabled(call); + assertNotNull(call.result); + assertFalse(call.result.getBoolean("value")); + } + + @Test + @SdkSuppress(minSdkVersion = 26) + public void missingOrEmptyIdentifierIsRejected() { + for (JSObject options : new JSObject[] { new JSObject(), new JSObject().put("id", "") }) { + RecordingCall call = new RecordingCall("checkChannelEnabled", options); + channelManager.checkChannelEnabled(call); + assertNull(call.result); + assertEquals("Channel missing identifier", call.errorMessage); + } + } + + @Test + @SdkSuppress(maxSdkVersion = 25) + public void channelChecksAreUnavailableBeforeAndroidO() { + RecordingCall call = new RecordingCall("checkChannelEnabled", new JSObject().put("id", "general")); + channelManager.checkChannelEnabled(call); + assertNull(call.result); + assertEquals("UNAVAILABLE", call.errorCode); + } + + private static class RecordingCall extends PluginCall { + + JSObject result; + String errorMessage; + String errorCode; + + RecordingCall(String method, JSObject options) { + super(null, "PushNotifications", "test", method, options); + } + + @Override + public void resolve(JSObject data) { + result = data; + } + + @Override + public void reject(String message, String code, Exception exception, JSObject data) { + errorMessage = message; + errorCode = code; + } + } +} diff --git a/push-notifications/android/src/main/java/com/capacitorjs/plugins/pushnotifications/NotificationChannelManager.java b/push-notifications/android/src/main/java/com/capacitorjs/plugins/pushnotifications/NotificationChannelManager.java index 3acf6a245a..d161fa384f 100644 --- a/push-notifications/android/src/main/java/com/capacitorjs/plugins/pushnotifications/NotificationChannelManager.java +++ b/push-notifications/android/src/main/java/com/capacitorjs/plugins/pushnotifications/NotificationChannelManager.java @@ -30,6 +30,7 @@ public NotificationChannelManager(Context context, NotificationManager manager, private static String CHANNEL_NAME = "name"; private static String CHANNEL_DESCRIPTION = "description"; private static String CHANNEL_IMPORTANCE = "importance"; + private static String CHANNEL_ENABLED = "enabled"; private static String CHANNEL_VISIBILITY = "visibility"; private static String CHANNEL_SOUND = "sound"; private static String CHANNEL_VIBRATE = "vibration"; @@ -111,6 +112,22 @@ public void deleteChannel(PluginCall call) { } } + public void checkChannelEnabled(PluginCall call) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + String channelId = call.getString(CHANNEL_ID); + if (channelId == null || channelId.isEmpty()) { + call.reject("Channel missing identifier"); + return; + } + NotificationChannel channel = notificationManager.getNotificationChannel(channelId); + JSObject result = new JSObject(); + result.put("value", channel != null && channel.getImportance() != NotificationManager.IMPORTANCE_NONE); + call.resolve(result); + } else { + call.unavailable(); + } + } + public void listChannels(PluginCall call) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { List notificationChannels = notificationManager.getNotificationChannels(); @@ -121,6 +138,7 @@ public void listChannels(PluginCall call) { channel.put(CHANNEL_NAME, notificationChannel.getName()); channel.put(CHANNEL_DESCRIPTION, notificationChannel.getDescription()); channel.put(CHANNEL_IMPORTANCE, notificationChannel.getImportance()); + channel.put(CHANNEL_ENABLED, notificationChannel.getImportance() != NotificationManager.IMPORTANCE_NONE); channel.put(CHANNEL_VISIBILITY, notificationChannel.getLockscreenVisibility()); channel.put(CHANNEL_SOUND, notificationChannel.getSound()); channel.put(CHANNEL_VIBRATE, notificationChannel.shouldVibrate()); diff --git a/push-notifications/android/src/main/java/com/capacitorjs/plugins/pushnotifications/PushNotificationsPlugin.java b/push-notifications/android/src/main/java/com/capacitorjs/plugins/pushnotifications/PushNotificationsPlugin.java index 63916663af..519be242d7 100644 --- a/push-notifications/android/src/main/java/com/capacitorjs/plugins/pushnotifications/PushNotificationsPlugin.java +++ b/push-notifications/android/src/main/java/com/capacitorjs/plugins/pushnotifications/PushNotificationsPlugin.java @@ -14,6 +14,7 @@ import android.os.Looper; import android.service.notification.StatusBarNotification; import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; import com.getcapacitor.*; import com.getcapacitor.annotation.CapacitorPlugin; import com.getcapacitor.annotation.Permission; @@ -87,6 +88,13 @@ protected void handleOnNewIntent(Intent data) { } } + @PluginMethod + public void checkAppEnabled(PluginCall call) { + JSObject result = new JSObject(); + result.put("value", NotificationManagerCompat.from(getContext()).areNotificationsEnabled()); + call.resolve(result); + } + @PluginMethod public void checkPermissions(PluginCall call) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { @@ -214,6 +222,11 @@ public void listChannels(PluginCall call) { notificationChannelManager.listChannels(call); } + @PluginMethod + public void checkChannelEnabled(PluginCall call) { + notificationChannelManager.checkChannelEnabled(call); + } + public void sendToken(String token) { JSObject data = new JSObject(); data.put("value", token); diff --git a/push-notifications/ios/Sources/PushNotificationsPlugin/PushNotificationsHandler.swift b/push-notifications/ios/Sources/PushNotificationsPlugin/PushNotificationsHandler.swift index 653d4eb5d4..61adbc11a9 100644 --- a/push-notifications/ios/Sources/PushNotificationsPlugin/PushNotificationsHandler.swift +++ b/push-notifications/ios/Sources/PushNotificationsPlugin/PushNotificationsHandler.swift @@ -17,6 +17,19 @@ public class PushNotificationsHandler: NSObject, NotificationHandlerProtocol { } } + public func checkAppEnabled(with completion: ((Bool) -> Void)? = nil) { + checkPermissions { status in + switch status { + case .authorized, .provisional, .ephemeral: + completion?(true) + case .notDetermined, .denied: + completion?(false) + @unknown default: + completion?(false) + } + } + } + public func willPresent(notification: UNNotification) -> UNNotificationPresentationOptions { let notificationData = makeNotificationRequestJSObject(notification.request) self.plugin?.notifyListeners("pushNotificationReceived", data: notificationData) diff --git a/push-notifications/ios/Sources/PushNotificationsPlugin/PushNotificationsPlugin.swift b/push-notifications/ios/Sources/PushNotificationsPlugin/PushNotificationsPlugin.swift index 9044c7493f..684b3db8f5 100644 --- a/push-notifications/ios/Sources/PushNotificationsPlugin/PushNotificationsPlugin.swift +++ b/push-notifications/ios/Sources/PushNotificationsPlugin/PushNotificationsPlugin.swift @@ -20,6 +20,8 @@ public class PushNotificationsPlugin: CAPPlugin, CAPBridgedPlugin { public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "register", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "unregister", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "checkAppEnabled", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "checkChannelEnabled", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getDeliveredNotifications", returnType: CAPPluginReturnPromise), @@ -96,6 +98,12 @@ public class PushNotificationsPlugin: CAPPlugin, CAPBridgedPlugin { } } + @objc func checkAppEnabled(_ call: CAPPluginCall) { + self.notificationDelegateHandler.checkAppEnabled { enabled in + call.resolve(["value": enabled]) + } + } + /** * Check notification permission */ @@ -181,6 +189,10 @@ public class PushNotificationsPlugin: CAPPlugin, CAPBridgedPlugin { call.unimplemented("Not available on iOS") } + @objc func checkChannelEnabled(_ call: CAPPluginCall) { + call.unimplemented("Not available on iOS") + } + @objc public func didRegisterForRemoteNotificationsWithDeviceToken(notification: NSNotification) { appDelegateRegistrationCalled = true if let deviceToken = notification.object as? Data { diff --git a/push-notifications/ios/Tests/PushNotificationsPluginTests/PushNotificationsPluginTests.swift b/push-notifications/ios/Tests/PushNotificationsPluginTests/PushNotificationsPluginTests.swift index 630b54ab1f..6d2d21f3fe 100644 --- a/push-notifications/ios/Tests/PushNotificationsPluginTests/PushNotificationsPluginTests.swift +++ b/push-notifications/ios/Tests/PushNotificationsPluginTests/PushNotificationsPluginTests.swift @@ -1,5 +1,60 @@ import XCTest +import Capacitor +import UserNotifications @testable import PushNotificationsPlugin class PushNotificationsTests: XCTestCase { + func testAppEnabledForCurrentAuthorizationStatus() { + let handler = TestPushNotificationsHandler() + let statuses: [(UNAuthorizationStatus, Bool)] = [ + (.notDetermined, false), + (.denied, false), + (.authorized, true), + (.provisional, true), + (.ephemeral, true) + ] + + for (status, expected) in statuses { + handler.status = status + let completion = expectation(description: "Authorization status \(status.rawValue)") + handler.checkAppEnabled { enabled in + XCTAssertEqual(enabled, expected) + completion.fulfill() + } + wait(for: [completion], timeout: 1) + } + } + + func testEnabledMethodsAreRegistered() { + let plugin = PushNotificationsPlugin() + for name in ["checkAppEnabled", "checkChannelEnabled"] { + XCTAssertTrue(plugin.pluginMethods.contains { $0.name == name && $0.returnType == CAPPluginReturnPromise }) + } + } + + func testCheckChannelEnabledIsUnimplemented() throws { + let rejected = expectation(description: "Channel checks are unavailable on iOS") + let call = try XCTUnwrap(CAPPluginCall( + callbackId: "test", + methodName: "checkChannelEnabled", + options: ["id": "general"], + success: { _, _ in XCTFail("Channel checks should not resolve on iOS") }, + error: { error in + XCTAssertEqual(error?.code, "UNIMPLEMENTED") + XCTAssertEqual(error?.message, "Not available on iOS") + rejected.fulfill() + } + )) + + PushNotificationsPlugin().checkChannelEnabled(call) + wait(for: [rejected], timeout: 1) + } +} + +private class TestPushNotificationsHandler: PushNotificationsHandler { + var status: UNAuthorizationStatus = .notDetermined + + override func checkPermissions(with completion: ((UNAuthorizationStatus) -> Void)? = nil) { + completion?(status) + } } diff --git a/push-notifications/src/definitions.ts b/push-notifications/src/definitions.ts index b457933ef8..91eeccdeb8 100644 --- a/push-notifications/src/definitions.ts +++ b/push-notifications/src/definitions.ts @@ -99,12 +99,36 @@ export interface PushNotificationsPlugin { */ listChannels(): Promise; + /** + * Check whether notifications are enabled for the app in the system settings. + * + * On iOS, authorized, provisional, and ephemeral authorization are considered enabled. + * This does not request permission or check push registration, individual channels, + * or temporary restrictions such as Do Not Disturb. + * + * @since 8.2.0 + */ + checkAppEnabled(): Promise; + + /** + * Check whether a notification channel exists and its importance is not `0` (disabled). + * + * Returns `false` if the channel does not exist. This does not check app-wide + * notification settings or channel group settings. Use `checkAppEnabled()` to + * check the app-wide setting separately. + * + * Only available on Android O or newer (SDK 26+). + * + * @since 8.2.0 + */ + checkChannelEnabled(args: { id: string }): Promise; + /** * Check permission to receive push notifications. * * On Android 12 and below the status is always granted because you can always * receive push notifications. If you need to check if the user allows - * to display notifications, use local-notifications plugin. + * to display notifications, use `checkAppEnabled()`. * * @since 1.0.0 */ @@ -370,6 +394,18 @@ export interface Channel { */ importance?: Importance; + /** + * Whether the channel's importance is not `0` (disabled). + * + * Returned by `listChannels()` and ignored by `createChannel()`. + * This does not include app-wide notification settings or channel group settings. + * + * Only available on Android O or newer (SDK 26+). + * + * @since 8.2.0 + */ + enabled?: boolean; + /** * The visibility of notifications posted to this channel. * @@ -429,6 +465,15 @@ export interface ListChannelsResult { channels: Channel[]; } +export interface EnabledResult { + /** + * Whether notifications are enabled for the app or the specified channel. + * + * @since 8.2.0 + */ + value: boolean; +} + export interface PermissionStatus { /** * Permission state of receiving notifications.