cordova-plugin-reteno 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/android.md CHANGED
@@ -58,7 +58,7 @@ RetenoPlugin.init()
58
58
  });
59
59
  ```
60
60
 
61
- For Android SDK `2.9.4+`, you can also configure session duration and split session lifecycle flags during init:
61
+ For Android SDK `2.9.6+`, you can also configure session duration and split session lifecycle flags during init:
62
62
 
63
63
  ```js
64
64
  RetenoPlugin.init({
@@ -66,7 +66,7 @@ RetenoPlugin.init({
66
66
  sessionDurationSeconds: 30 * 60,
67
67
  lifecycleTrackingOptions: {
68
68
  appLifecycleEnabled: true,
69
- // ApplicationForgrounded/ApplicationBackgrounded events (disabled by default in 2.9.4)
69
+ // ApplicationForgrounded/ApplicationBackgrounded events (disabled by default)
70
70
  foregroundLifecycleEnabled: false,
71
71
  pushSubscriptionEnabled: true,
72
72
  // SessionStart/SessionEnd can now be managed separately
@@ -84,7 +84,7 @@ If you need advanced Reteno configuration (custom `RetenoConfig`, custom device
84
84
 
85
85
  ## Push notification listeners
86
86
 
87
- The plugin uses the listener-based API (`EventListener` / `Procedure`, Android SDK `2.9.4`) for all push events.
87
+ The plugin uses the listener-based API (`EventListener` / `Procedure`, Android SDK `2.9.6`) for all push events.
88
88
 
89
89
  - `setOnRetenoPushDismissedListener(listener)` — called when a push notification is dismissed (swiped away).
90
90
  - `setOnRetenoCustomPushReceivedListener(listener)` — called when a custom push notification is received.
@@ -108,6 +108,23 @@ RetenoPlugin.removeOnRetenoPushDismissedListener(onDismissed);
108
108
  RetenoPlugin.removeOnRetenoCustomPushReceivedListener(onCustomReceived);
109
109
  ```
110
110
 
111
+ ## Notification grouping
112
+
113
+ Android notifications can be assigned to a group using either a value from the push payload or a constant group ID. The configuration is stored natively and restored when the Android process starts, so it does not depend on the Cordova WebView being active.
114
+
115
+ ```js
116
+ // Group notifications by the `chatId` field in the push payload.
117
+ await RetenoPlugin.setNotificationGroupingRule({ payloadKey: 'chatId' });
118
+
119
+ // Alternatively, assign all Reteno notifications to one group.
120
+ await RetenoPlugin.setNotificationGroupingRule({ groupId: 'messages' });
121
+
122
+ // Disable grouping.
123
+ await RetenoPlugin.setNotificationGroupingRule(null);
124
+ ```
125
+
126
+ This configures the group key on each notification. If you want Android to display a summary row for the group, the application must create its own notification with `setGroupSummary(true)` using the same group ID.
127
+
111
128
  ### Without Firebasex (Cordova)
112
129
 
113
130
  If you **do not need** `cordova-plugin-firebasex` features, you can still use Reteno Push on Android.
package/docs/api.md CHANGED
@@ -12,17 +12,17 @@ Notes:
12
12
 
13
13
  | Method | Supported platform | Description |
14
14
  | ---------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
- | [init](../www/cordova-plugin-reteno.js) | iOS, Android | Initializes Reteno SDK. Accepts optional `RetenoInitializeOptions` with `accessKey`, `pauseInAppMessages`, `pausePushInAppMessages`, `inAppMessagesPauseBehaviour` (iOS), `lifecycleTrackingOptions`, `sessionDurationSeconds` (Android 2.9.4+, iOS 2.7.0+), `isAutomaticScreenReportingEnabled` (iOS; see note below) and `isDebugMode`. [Types](../types/index.ts) |
15
+ | [init](../www/cordova-plugin-reteno.js) | iOS, Android | Initializes Reteno SDK. Accepts optional `RetenoInitializeOptions` with `accessKey`, `pauseInAppMessages`, `pausePushInAppMessages`, `inAppMessagesPauseBehaviour` (iOS), `lifecycleTrackingOptions`, `sessionDurationSeconds` (Android 2.9.6+, iOS 2.7.2+), `isAutomaticScreenReportingEnabled` (iOS; see note below) and `isDebugMode`. [Types](../types/index.ts) |
16
16
  | [requestNotificationPermission](../www/cordova-plugin-reteno.js) | iOS, Android | Requests push permission (iOS) or `POST_NOTIFICATIONS` (Android 13+). Returns `0` or `1` on Android (`RequestNotificationPermissionResult`) in [types](../types/index.ts). |
17
17
  | [setLifecycleTrackingOptions](../www/cordova-plugin-reteno.js) | iOS, Android | Configures automatic tracking for app lifecycle, push subscription, and session events. Android: applies immediately. iOS: supported only before initialization (stored and applied during `init(...)`). [Types](../types/index.ts) |
18
18
 
19
19
  ### User data
20
20
 
21
- | Method | Supported platform | Description |
22
- | ---------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------ |
23
- | [setUserAttributes](../www/cordova-plugin-reteno.js) | iOS, Android | Supports `marketId` (Android 2.9.5+, iOS 2.7.1+). Pass `""` to clear. [Types](../types/index.ts) |
24
- | [setAnonymousUserAttributes](../www/cordova-plugin-reteno.js) | iOS, Android | Supports `marketId` (Android 2.9.5+, iOS 2.7.1+). Pass `""` to clear. [Types](../types/index.ts) |
25
- | [setMultiAccountUserAttributes](../www/cordova-plugin-reteno.js) | iOS, Android | Supports `marketId` (Android 2.9.5+, iOS 2.7.1+). Pass `""` to clear. [Types](../types/index.ts) |
21
+ | Method | Supported platform | Description |
22
+ | ---------------------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
23
+ | [setUserAttributes](../www/cordova-plugin-reteno.js) | iOS, Android | Supports `marketId` with the bundled Android 2.9.6+ and iOS 2.7.2+ SDKs. Pass `""` to clear. [Types](../types/index.ts) |
24
+ | [setAnonymousUserAttributes](../www/cordova-plugin-reteno.js) | iOS, Android | Supports `marketId` with the bundled Android 2.9.6+ and iOS 2.7.2+ SDKs. Pass `""` to clear. [Types](../types/index.ts) |
25
+ | [setMultiAccountUserAttributes](../www/cordova-plugin-reteno.js) | iOS, Android | Supports `marketId` with the bundled Android 2.9.6+ and iOS 2.7.2+ SDKs. Pass `""` to clear. [Types](../types/index.ts) |
26
26
 
27
27
  ### Event tracking
28
28
 
@@ -53,9 +53,12 @@ Notes:
53
53
  | [setWillPresentNotificationOptions](../www/cordova-plugin-reteno.js) | iOS | Sets presentation options for foreground notifications. Optionally emits `reteno-push-received`. [Types](../types/index.ts) |
54
54
  | [setDidReceiveNotificationResponseHandler](../www/cordova-plugin-reteno.js) | iOS | Enables a response handler for notification taps. Optionally emits `reteno-notification-clicked`. [Types](../types/index.ts) |
55
55
  | [updateDefaultNotificationChannel](../www/cordova-plugin-reteno.js) | Android | Updates the default notification channel name and description for existing users. [Types](../types/index.ts) |
56
+ | [setNotificationGroupingRule](../www/cordova-plugin-reteno.js) | Android | Groups notifications by a push payload field or a constant group ID. The rule is persisted natively and restored before background push handling. Pass `null` to disable. [Types](../types/index.ts) |
56
57
 
57
58
  ### In-app messages
58
59
 
60
+ Bundled native SDKs support the `DOES_NOT_EQUAL` in-app rule condition: Android SDK 2.9.6+ and iOS SDK 2.7.2+.
61
+
59
62
  | Method | Supported platform | Description |
60
63
  | --------------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
61
64
  | [pauseInAppMessages](../www/cordova-plugin-reteno.js) | iOS, Android | Pauses or resumes in-app messages at runtime. Pass `true` to pause, `false` to resume. |
@@ -99,7 +102,7 @@ RetenoPlugin.init()
99
102
  // pausePushInAppMessages: pauses in-app messages triggered by push notifications.
100
103
  // inAppMessagesPauseBehaviour: (iOS) defines handling while paused: 'SKIP_IN_APPS' or 'POSTPONE_IN_APPS'.
101
104
  // lifecycleTrackingOptions: configures app lifecycle, push subscription, and session event tracking ('ALL', 'NONE', or an object).
102
- // sessionDurationSeconds (Android 2.9.4+, iOS 2.7.0+): optional session reset duration.
105
+ // sessionDurationSeconds (Android 2.9.6+, iOS 2.7.2+): optional session reset duration.
103
106
  // isAutomaticScreenReportingEnabled: enables automatic native screen view tracking on iOS.
104
107
  // Keep this false in Cordova/Ionic WebView apps (including Android demos).
105
108
  // Defaults to false. Note: in Cordova/Ionic apps the UI runs inside a single WebView,
@@ -116,13 +119,13 @@ RetenoPlugin.init({
116
119
  inAppMessagesPauseBehaviour: 'SKIP_IN_APPS', // iOS
117
120
  isAutomaticScreenReportingEnabled: false, // keep false for hybrid/WebView apps
118
121
  isDebugMode: true, // enables debug mode
119
- sessionDurationSeconds: 30 * 60, // Android 2.9.4+, iOS 2.7.0+: 30 minutes
122
+ sessionDurationSeconds: 30 * 60, // Android 2.9.6+, iOS 2.7.2+: 30 minutes
120
123
  lifecycleTrackingOptions: {
121
124
  appLifecycleEnabled: true,
122
125
  foregroundLifecycleEnabled: false,
123
126
  pushSubscriptionEnabled: true,
124
127
  sessionStartEventsEnabled: true,
125
- sessionEndEventsEnabled: false, // Android 2.9.4 default
128
+ sessionEndEventsEnabled: false,
126
129
  },
127
130
  })
128
131
  .then(() => console.log('init: OK'))
@@ -483,7 +486,7 @@ Subscribe to push received events while the app is running.
483
486
 
484
487
  Notes:
485
488
 
486
- - On Android this is based on the SDK 2.9.4 `EventListener` / `Procedure` API (`RetenoNotifications.getReceived()`).
489
+ - On Android this is based on the SDK 2.9.6 `EventListener` / `Procedure` API (`RetenoNotifications.getReceived()`).
487
490
  - This listener is not a replacement for `getInitialNotification()` (cold start): if the app was launched by tapping a notification, use `getInitialNotification()`.
488
491
 
489
492
  ```js
@@ -565,6 +568,23 @@ RetenoPlugin.updateDefaultNotificationChannel({
565
568
  .catch((err) => console.error('updateDefaultNotificationChannel: ERROR', err));
566
569
  ```
567
570
 
571
+ ### setNotificationGroupingRule example
572
+
573
+ Configures Android notification grouping without a JavaScript callback, so the rule also works when the WebView is not running. Payload type: `NotificationGroupingRule` in [types](../types/index.ts).
574
+
575
+ ```js
576
+ // Use the value of `chatId` from each push payload as its Android group key.
577
+ await RetenoPlugin.setNotificationGroupingRule({ payloadKey: 'chatId' });
578
+
579
+ // Or put every Reteno notification into one constant group.
580
+ await RetenoPlugin.setNotificationGroupingRule({ groupId: 'messages' });
581
+
582
+ // Disable grouping.
583
+ await RetenoPlugin.setNotificationGroupingRule(null);
584
+ ```
585
+
586
+ The SDK assigns `NotificationCompat.Builder.setGroup(...)`; displaying a group-summary notification remains the application's responsibility.
587
+
568
588
  ### getAppInboxMessages example
569
589
 
570
590
  Fetches paginated App Inbox messages. Payload type: `GetAppInboxMessagesPayload` in [types](../types/index.ts).
package/docs/ios.md CHANGED
@@ -68,12 +68,12 @@ target 'App' do
68
68
 
69
69
  target 'NotificationServiceExtension' do
70
70
  inherit! :search_paths
71
- pod 'Reteno', '2.7.0'
71
+ pod 'Reteno', '2.7.3'
72
72
  end
73
73
 
74
74
  target 'NotificationContentExtension' do
75
75
  inherit! :search_paths
76
- pod 'Reteno', '2.7.0'
76
+ pod 'Reteno', '2.7.3'
77
77
  end
78
78
  end
79
79
  ```
@@ -186,12 +186,12 @@ target 'App' do
186
186
 
187
187
  target 'NotificationServiceExtension' do
188
188
  inherit! :search_paths
189
- pod 'Reteno', '2.7.0'
189
+ pod 'Reteno', '2.7.3'
190
190
  end
191
191
 
192
192
  target 'NotificationContentExtension' do
193
193
  inherit! :search_paths
194
- pod 'Reteno', '2.7.0'
194
+ pod 'Reteno', '2.7.3'
195
195
  end
196
196
  end
197
197
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cordova-plugin-reteno",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "The Reteno Cordova Plugin for Mobile Customer Engagement and Analytics solutions",
5
5
  "types": "./types/index.ts",
6
6
  "files": [
package/plugin.xml CHANGED
@@ -1,5 +1,5 @@
1
1
  <?xml version='1.0' encoding='utf-8'?>
2
- <plugin id="cordova-plugin-reteno" version="2.1.0" xmlns="http://apache.org/cordova/ns/plugins/1.0" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
2
+ <plugin id="cordova-plugin-reteno" version="2.2.0" xmlns="http://apache.org/cordova/ns/plugins/1.0" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
3
3
  <name>cordova-plugin-reteno</name>
4
4
  <engines>
5
5
  <engine name="cordova-android" version=">=12.0.0" />
@@ -22,6 +22,10 @@
22
22
 
23
23
  <config-file target="AndroidManifest.xml" parent="/manifest/application">
24
24
  <meta-data android:name="com.reteno.SDK_ACCESS_KEY" android:value="$SDK_ACCESS_KEY" />
25
+ <provider
26
+ android:name="com.reteno.plugin.RetenoNotificationGroupingRuleProvider"
27
+ android:authorities="${applicationId}.reteno.notification-grouping-rule"
28
+ android:exported="false" />
25
29
  </config-file>
26
30
 
27
31
  <config-file target="config.xml" parent="/*">
@@ -45,6 +49,12 @@
45
49
  <source-file
46
50
  src="src/android/app/src/main/java/com/reteno/plugin/RetenoUtil.java"
47
51
  target-dir="src/com/reteno/plugin" />
52
+ <source-file
53
+ src="src/android/app/src/main/java/com/reteno/plugin/RetenoNotificationGroupingRuleProvider.java"
54
+ target-dir="src/com/reteno/plugin" />
55
+ <source-file
56
+ src="src/android/app/src/main/java/com/reteno/plugin/RetenoNotificationSummaryManager.java"
57
+ target-dir="src/com/reteno/plugin" />
48
58
  </platform>
49
59
 
50
60
  <platform name="ios">
@@ -58,7 +68,7 @@
58
68
 
59
69
  <podspec>
60
70
  <pods>
61
- <pod name="Reteno" spec="2.7.1" />
71
+ <pod name="Reteno" spec="2.7.3" />
62
72
  <pod name="FirebaseMessaging" />
63
73
  </pods>
64
74
  </podspec>
@@ -1,6 +1,6 @@
1
1
  apply plugin: 'com.android.application'
2
2
 
3
- def retenoFcmVersion = '2.9.4'
3
+ def retenoFcmVersion = '2.10.1'
4
4
 
5
5
  android {
6
6
  namespace 'com.reteno.plugin'
@@ -2,6 +2,11 @@
2
2
  <manifest xmlns:android="http://schemas.android.com/apk/res/android"
3
3
  package="com.reteno.plugin">
4
4
 
5
- <application />
5
+ <application>
6
+ <provider
7
+ android:name="com.reteno.plugin.RetenoNotificationGroupingRuleProvider"
8
+ android:authorities="${applicationId}.reteno.notification-grouping-rule"
9
+ android:exported="false" />
10
+ </application>
6
11
 
7
12
  </manifest>
@@ -0,0 +1,117 @@
1
+ package com.reteno.plugin;
2
+
3
+ import android.content.ContentProvider;
4
+ import android.content.ContentValues;
5
+ import android.content.Context;
6
+ import android.content.SharedPreferences;
7
+ import android.database.Cursor;
8
+ import android.net.Uri;
9
+ import android.text.TextUtils;
10
+
11
+ import com.reteno.push.RetenoNotifications;
12
+
13
+ import java.util.Map;
14
+
15
+ /**
16
+ * Restores the declarative notification grouping rule before services handle a push.
17
+ * This keeps grouping available when the Cordova WebView has not started yet.
18
+ */
19
+ public final class RetenoNotificationGroupingRuleProvider extends ContentProvider {
20
+ private static final String PREFERENCES = "com.reteno.plugin.notification-grouping-rule";
21
+ private static final String PAYLOAD_KEY = "payloadKey";
22
+ private static final String GROUP_ID = "groupId";
23
+ private static final String SHOW_SUMMARY = "showSummary";
24
+
25
+ @Override
26
+ public boolean onCreate() {
27
+ Context context = getContext();
28
+ if (context != null) {
29
+ restore(context);
30
+ }
31
+ return true;
32
+ }
33
+
34
+ static void configure(Context context, String payloadKey, String groupId, boolean showSummary) {
35
+ SharedPreferences.Editor editor = preferences(context).edit().clear();
36
+ if (!TextUtils.isEmpty(payloadKey)) {
37
+ editor.putString(PAYLOAD_KEY, payloadKey);
38
+ } else if (!TextUtils.isEmpty(groupId)) {
39
+ editor.putString(GROUP_ID, groupId);
40
+ }
41
+ if (showSummary) {
42
+ editor.putBoolean(SHOW_SUMMARY, true);
43
+ }
44
+ // Persist before returning so a process restart cannot lose a just-configured rule.
45
+ editor.commit();
46
+ install(context, payloadKey, groupId, showSummary);
47
+ }
48
+
49
+ private static void restore(Context context) {
50
+ SharedPreferences preferences = preferences(context);
51
+ install(
52
+ context,
53
+ preferences.getString(PAYLOAD_KEY, null),
54
+ preferences.getString(GROUP_ID, null),
55
+ preferences.getBoolean(SHOW_SUMMARY, false)
56
+ );
57
+ }
58
+
59
+ /** Resolves the group a received push belongs to under the persisted rule. */
60
+ static String resolveGroup(Context context, Map<String, String> payload) {
61
+ SharedPreferences preferences = preferences(context);
62
+ String payloadKey = preferences.getString(PAYLOAD_KEY, null);
63
+ if (!TextUtils.isEmpty(payloadKey)) {
64
+ String value = payload.get(payloadKey);
65
+ return TextUtils.isEmpty(value) ? null : value;
66
+ }
67
+ String groupId = preferences.getString(GROUP_ID, null);
68
+ return TextUtils.isEmpty(groupId) ? null : groupId;
69
+ }
70
+
71
+ private static SharedPreferences preferences(Context context) {
72
+ return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
73
+ }
74
+
75
+ private static void install(Context context, String payloadKey, String groupId, boolean showSummary) {
76
+ if (!TextUtils.isEmpty(payloadKey)) {
77
+ RetenoNotifications.setGroupingRule((Map<String, String> payload) -> {
78
+ String value = payload.get(payloadKey);
79
+ return TextUtils.isEmpty(value) ? null : value;
80
+ });
81
+ } else if (!TextUtils.isEmpty(groupId)) {
82
+ RetenoNotifications.setGroupingRule((Map<String, String> payload) -> groupId);
83
+ } else {
84
+ RetenoNotifications.setGroupingRule(null);
85
+ }
86
+ RetenoNotificationSummaryManager.setEnabled(
87
+ context,
88
+ showSummary && (!TextUtils.isEmpty(payloadKey) || !TextUtils.isEmpty(groupId)),
89
+ !TextUtils.isEmpty(payloadKey) ? "payloadKey:" + payloadKey : "groupId:" + groupId
90
+ );
91
+ }
92
+
93
+ @Override
94
+ public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
95
+ return null;
96
+ }
97
+
98
+ @Override
99
+ public String getType(Uri uri) {
100
+ return null;
101
+ }
102
+
103
+ @Override
104
+ public Uri insert(Uri uri, ContentValues values) {
105
+ return null;
106
+ }
107
+
108
+ @Override
109
+ public int delete(Uri uri, String selection, String[] selectionArgs) {
110
+ return 0;
111
+ }
112
+
113
+ @Override
114
+ public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
115
+ return 0;
116
+ }
117
+ }
@@ -0,0 +1,262 @@
1
+ package com.reteno.plugin;
2
+
3
+ import android.Manifest;
4
+ import android.app.Notification;
5
+ import android.app.NotificationChannel;
6
+ import android.app.NotificationManager;
7
+ import android.content.Context;
8
+ import android.content.pm.PackageManager;
9
+ import android.os.Bundle;
10
+ import android.os.Handler;
11
+ import android.os.Looper;
12
+ import android.service.notification.StatusBarNotification;
13
+
14
+ import androidx.core.app.ActivityCompat;
15
+ import androidx.core.app.NotificationCompat;
16
+ import androidx.core.app.NotificationManagerCompat;
17
+
18
+ import com.reteno.core.util.Procedure;
19
+ import com.reteno.push.RetenoNotifications;
20
+
21
+ import java.util.HashMap;
22
+ import java.util.Map;
23
+ import java.util.Objects;
24
+
25
+ /** Maintains optional Android group-summary notifications for Reteno pushes. */
26
+ final class RetenoNotificationSummaryManager {
27
+ private static final String SUMMARY_CHANNEL_ID = "reteno_group_summary";
28
+ private static final String SUMMARY_TAG_PREFIX = "com.reteno.plugin.group-summary:";
29
+ private static final int SUMMARY_NOTIFICATION_ID = 1;
30
+ private static final int MAX_POST_ATTEMPTS = 25;
31
+ private static final int MAX_REMOVAL_ATTEMPTS = 10;
32
+ private static final long RETRY_DELAY_MS = 200L;
33
+
34
+ private static RetenoNotificationSummaryManager instance;
35
+
36
+ private final Context context;
37
+ private final Handler handler = new Handler(Looper.getMainLooper());
38
+ private final NotificationManagerCompat notificationManager;
39
+
40
+ private Procedure<Bundle> receivedListener;
41
+ private Procedure<Bundle> clickListener;
42
+ private Procedure<Bundle> closeListener;
43
+ private boolean enabled;
44
+ private String ruleIdentity;
45
+
46
+ private RetenoNotificationSummaryManager(Context context) {
47
+ this.context = context.getApplicationContext();
48
+ this.notificationManager = NotificationManagerCompat.from(this.context);
49
+ }
50
+
51
+ static synchronized void setEnabled(Context context, boolean enabled, String ruleIdentity) {
52
+ if (instance == null) {
53
+ instance = new RetenoNotificationSummaryManager(context);
54
+ }
55
+ if (enabled) {
56
+ instance.enable(ruleIdentity);
57
+ } else {
58
+ instance.disable();
59
+ }
60
+ }
61
+
62
+ private void enable(String ruleIdentity) {
63
+ if (enabled) {
64
+ if (Objects.equals(this.ruleIdentity, ruleIdentity)) {
65
+ return;
66
+ }
67
+ // The grouping rule may have changed since we were last enabled (e.g. constant group
68
+ // "A" -> "B"). Group "A" will never receive another received/click/close event under
69
+ // the new rule, so its summary would otherwise be orphaned forever. Drop any pending
70
+ // retry first - it closed over the old group and would otherwise recreate a stale
71
+ // summary via reconcile() right after we cancel it below - then cancel every summary
72
+ // this manager owns; each will be rebuilt from scratch as traffic for the new rule
73
+ // comes in. cancelAllSummaries() only touches our own SUMMARY_TAG_PREFIX-tagged
74
+ // notifications, so it never disturbs a group some other feature of the host app uses.
75
+ handler.removeCallbacksAndMessages(null);
76
+ cancelAllSummaries();
77
+ this.ruleIdentity = ruleIdentity;
78
+ return;
79
+ }
80
+ enabled = true;
81
+ this.ruleIdentity = ruleIdentity;
82
+ createSummaryChannel();
83
+
84
+ receivedListener = this::onReceived;
85
+ clickListener = this::onRemoved;
86
+ closeListener = this::onRemoved;
87
+ RetenoNotifications.getReceived().addListener(receivedListener);
88
+ RetenoNotifications.getClick().addListener(clickListener);
89
+ RetenoNotifications.getClose().addListener(closeListener);
90
+ }
91
+
92
+ private void disable() {
93
+ enabled = false;
94
+ ruleIdentity = null;
95
+ handler.removeCallbacksAndMessages(null);
96
+ if (receivedListener != null) {
97
+ RetenoNotifications.getReceived().removeListener(receivedListener);
98
+ receivedListener = null;
99
+ }
100
+ if (clickListener != null) {
101
+ RetenoNotifications.getClick().removeListener(clickListener);
102
+ clickListener = null;
103
+ }
104
+ if (closeListener != null) {
105
+ RetenoNotifications.getClose().removeListener(closeListener);
106
+ closeListener = null;
107
+ }
108
+ cancelAllSummaries();
109
+ }
110
+
111
+ private void createSummaryChannel() {
112
+ NotificationChannel channel = new NotificationChannel(
113
+ SUMMARY_CHANNEL_ID,
114
+ "Grouped notifications summary",
115
+ NotificationManager.IMPORTANCE_DEFAULT
116
+ );
117
+ notificationManager.createNotificationChannel(channel);
118
+ }
119
+
120
+ private void onReceived(Bundle bundle) {
121
+ String group = resolveGroup(bundle);
122
+ if (group == null) {
123
+ return;
124
+ }
125
+ awaitChildAndReconcile(group, notificationId(bundle), 0);
126
+ }
127
+
128
+ private void awaitChildAndReconcile(String group, int notificationId, int attempt) {
129
+ if (!enabled) {
130
+ return;
131
+ }
132
+ if (hasChild(group, notificationId) || attempt >= MAX_POST_ATTEMPTS) {
133
+ reconcile(group);
134
+ return;
135
+ }
136
+ handler.postDelayed(
137
+ () -> awaitChildAndReconcile(group, notificationId, attempt + 1),
138
+ RETRY_DELAY_MS
139
+ );
140
+ }
141
+
142
+ private void onRemoved(Bundle bundle) {
143
+ if (!enabled) {
144
+ return;
145
+ }
146
+ String group = resolveGroup(bundle);
147
+ if (group == null) {
148
+ return;
149
+ }
150
+ awaitRemovalAndReconcile(group, notificationId(bundle), 0);
151
+ }
152
+
153
+ private void awaitRemovalAndReconcile(String group, int notificationId, int attempt) {
154
+ if (!enabled) {
155
+ return;
156
+ }
157
+ if (hasChild(group, notificationId) && attempt < MAX_REMOVAL_ATTEMPTS) {
158
+ handler.postDelayed(
159
+ () -> awaitRemovalAndReconcile(group, notificationId, attempt + 1),
160
+ RETRY_DELAY_MS
161
+ );
162
+ return;
163
+ }
164
+ reconcile(group);
165
+ }
166
+
167
+ private String resolveGroup(Bundle bundle) {
168
+ return RetenoNotificationGroupingRuleProvider.resolveGroup(context, toStringMap(bundle));
169
+ }
170
+
171
+ private Map<String, String> toStringMap(Bundle bundle) {
172
+ Map<String, String> payload = new HashMap<>();
173
+ if (bundle == null) {
174
+ return payload;
175
+ }
176
+ for (String key : bundle.keySet()) {
177
+ Object value = bundle.get(key);
178
+ payload.put(key, value == null ? null : value.toString());
179
+ }
180
+ return payload;
181
+ }
182
+
183
+ private int notificationId(Bundle bundle) {
184
+ String interactionId = bundle == null ? null : bundle.getString("es_interaction_id");
185
+ return interactionId == null ? 1 : interactionId.hashCode();
186
+ }
187
+
188
+ private boolean hasChild(String group, int notificationId) {
189
+ for (StatusBarNotification sbn : notificationManager.getActiveNotifications()) {
190
+ if (sbn.getId() == notificationId && isChildInGroup(sbn.getNotification(), group)) {
191
+ return true;
192
+ }
193
+ }
194
+ return false;
195
+ }
196
+
197
+ private void reconcile(String group) {
198
+ if (!enabled) {
199
+ return;
200
+ }
201
+
202
+ int childCount = 0;
203
+ for (StatusBarNotification sbn : notificationManager.getActiveNotifications()) {
204
+ if (isChildInGroup(sbn.getNotification(), group)) {
205
+ childCount++;
206
+ }
207
+ }
208
+
209
+ String tag = summaryTag(group);
210
+ if (childCount < 2) {
211
+ notificationManager.cancel(tag, SUMMARY_NOTIFICATION_ID);
212
+ return;
213
+ }
214
+ if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
215
+ != PackageManager.PERMISSION_GRANTED) {
216
+ return;
217
+ }
218
+
219
+ Notification summary = new NotificationCompat.Builder(context, SUMMARY_CHANNEL_ID)
220
+ .setContentTitle("New notifications")
221
+ .setContentText("You have " + childCount + " new notifications")
222
+ .setSmallIcon(resolveSmallIcon())
223
+ .setGroup(group)
224
+ .setGroupSummary(true)
225
+ .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
226
+ .setOnlyAlertOnce(true)
227
+ .build();
228
+
229
+ notificationManager.notify(tag, SUMMARY_NOTIFICATION_ID, summary);
230
+ }
231
+
232
+ /**
233
+ * The launcher icon (context.getApplicationInfo().icon) is commonly an adaptive icon on modern
234
+ * apps, which is not a valid notification small icon and can make the system reject the
235
+ * notification. Reuse the plain vector drawable the Reteno Android SDK itself ships and falls
236
+ * back to (RetenoSdkPush's "reteno_default_push_icon"), which is guaranteed to be merged into
237
+ * any app that depends on the SDK, instead of ever touching the launcher icon.
238
+ */
239
+ private int resolveSmallIcon() {
240
+ int icon = context.getResources().getIdentifier(
241
+ "reteno_default_push_icon", "drawable", context.getPackageName()
242
+ );
243
+ return icon != 0 ? icon : android.R.drawable.ic_dialog_info;
244
+ }
245
+
246
+ private boolean isChildInGroup(Notification notification, String group) {
247
+ return group.equals(notification.getGroup()) && !NotificationCompat.isGroupSummary(notification);
248
+ }
249
+
250
+ private String summaryTag(String group) {
251
+ return SUMMARY_TAG_PREFIX + group;
252
+ }
253
+
254
+ private void cancelAllSummaries() {
255
+ for (StatusBarNotification sbn : notificationManager.getActiveNotifications()) {
256
+ String tag = sbn.getTag();
257
+ if (tag != null && tag.startsWith(SUMMARY_TAG_PREFIX)) {
258
+ notificationManager.cancel(tag, sbn.getId());
259
+ }
260
+ }
261
+ }
262
+ }
@@ -353,6 +353,25 @@ public class RetenoPlugin extends CordovaPlugin {
353
353
  return true;
354
354
  }
355
355
 
356
+ if ("setNotificationGroupingRule".equals(action)) {
357
+ Activity activity = cordova.getActivity();
358
+ if (activity == null) {
359
+ callbackContext.error("Reteno Android SDK Error: Activity is null.");
360
+ return true;
361
+ }
362
+ activity.runOnUiThread(new Runnable() {
363
+ @Override
364
+ public void run() {
365
+ try {
366
+ setNotificationGroupingRule(args, callbackContext);
367
+ } catch (Exception e) {
368
+ callbackContext.error("Reteno Android SDK Error: " + e.getLocalizedMessage());
369
+ }
370
+ }
371
+ });
372
+ return true;
373
+ }
374
+
356
375
  if ("getAppInboxMessages".equals(action)) {
357
376
  cordova.getThreadPool().execute(new Runnable() {
358
377
  @Override
@@ -608,7 +627,7 @@ public class RetenoPlugin extends CordovaPlugin {
608
627
  }
609
628
 
610
629
  /**
611
- * Register notification event listeners using the SDK 2.9.4 EventListener API.
630
+ * Register notification event listeners using the SDK 2.9.6 EventListener API.
612
631
  * Each listener is a direct {@link Procedure} implementation — no reflection needed.
613
632
  */
614
633
  private void registerNotificationListeners() {
@@ -1181,7 +1200,7 @@ public class RetenoPlugin extends CordovaPlugin {
1181
1200
  ? payload.optBoolean("sessionStartEventsEnabled", legacySessionEventsEnabled)
1182
1201
  : legacySessionEventsEnabled;
1183
1202
 
1184
- // SDK 2.9.4 default for session end is disabled unless explicitly enabled.
1203
+ // SDK default for session end is disabled unless explicitly enabled.
1185
1204
  boolean sessionEndEventsEnabled = payload.has("sessionEndEventsEnabled")
1186
1205
  ? payload.optBoolean("sessionEndEventsEnabled", false)
1187
1206
  : (payload.has("sessionEventsEnabled") ? legacySessionEventsEnabled : false);
@@ -1320,6 +1339,61 @@ public class RetenoPlugin extends CordovaPlugin {
1320
1339
  }
1321
1340
  }
1322
1341
 
1342
+ private void setNotificationGroupingRule(JSONArray args, CallbackContext callbackContext) {
1343
+ Object arg0 = (args == null || args.length() == 0) ? null : args.opt(0);
1344
+ if (arg0 instanceof JSONArray) {
1345
+ arg0 = ((JSONArray) arg0).opt(0);
1346
+ }
1347
+
1348
+ if (arg0 == null || arg0 == JSONObject.NULL) {
1349
+ RetenoNotificationGroupingRuleProvider.configure(
1350
+ cordova.getActivity().getApplicationContext(), null, null, false
1351
+ );
1352
+ callbackContext.success(1);
1353
+ return;
1354
+ }
1355
+
1356
+ if (!(arg0 instanceof JSONObject)) {
1357
+ callbackContext.error("Invalid argument: expected null or an object with payloadKey or groupId");
1358
+ return;
1359
+ }
1360
+
1361
+ JSONObject config = (JSONObject) arg0;
1362
+ Object rawPayloadKey = config.opt("payloadKey");
1363
+ Object rawGroupId = config.opt("groupId");
1364
+ Object rawShowSummary = config.opt("showSummary");
1365
+ if ((rawPayloadKey != null && rawPayloadKey != JSONObject.NULL && !(rawPayloadKey instanceof String))
1366
+ || (rawGroupId != null && rawGroupId != JSONObject.NULL && !(rawGroupId instanceof String))) {
1367
+ callbackContext.error("Invalid argument: payloadKey and groupId must be strings");
1368
+ return;
1369
+ }
1370
+ if (rawShowSummary != null && rawShowSummary != JSONObject.NULL
1371
+ && !(rawShowSummary instanceof Boolean)) {
1372
+ callbackContext.error("Invalid argument: showSummary must be a boolean");
1373
+ return;
1374
+ }
1375
+
1376
+ String payloadKey = rawPayloadKey instanceof String ? (String) rawPayloadKey : null;
1377
+ String groupId = rawGroupId instanceof String ? (String) rawGroupId : null;
1378
+ payloadKey = payloadKey == null ? null : payloadKey.trim();
1379
+ groupId = groupId == null ? null : groupId.trim();
1380
+
1381
+ boolean hasPayloadKey = !TextUtils.isEmpty(payloadKey);
1382
+ boolean hasGroupId = !TextUtils.isEmpty(groupId);
1383
+ if (hasPayloadKey == hasGroupId) {
1384
+ callbackContext.error("Invalid argument: provide exactly one of payloadKey or groupId");
1385
+ return;
1386
+ }
1387
+
1388
+ RetenoNotificationGroupingRuleProvider.configure(
1389
+ cordova.getActivity().getApplicationContext(),
1390
+ hasPayloadKey ? payloadKey : null,
1391
+ hasGroupId ? groupId : null,
1392
+ rawShowSummary instanceof Boolean && (Boolean) rawShowSummary
1393
+ );
1394
+ callbackContext.success(1);
1395
+ }
1396
+
1323
1397
  private void getAppInboxMessages(JSONArray args, CallbackContext callbackContext) throws JSONException {
1324
1398
  if (args == null || args.length() == 0) {
1325
1399
  callbackContext.error("Missing argument: payload");
@@ -5,7 +5,7 @@ allprojects {
5
5
  }
6
6
  }
7
7
 
8
- def retenoFcmVersion = '2.9.5'
8
+ def retenoFcmVersion = '2.10.1'
9
9
 
10
10
  def firebaseMessagingVersion = '23.1.0'
11
11
 
@@ -117,7 +117,7 @@ class RetenoPlugin: CDVPlugin {
117
117
  isAutomaticScreenReportingEnabled: screenReportingEnabled,
118
118
  isAutomaticAppLifecycleReportingEnabled: lifecycleAppEnabled,
119
119
  isApplicationForegroundLifecycleReportingEnabled: lifecycleForegroundEnabled,
120
- isAutomaticPushSubsriptionReportingEnabled: lifecyclePushEnabled,
120
+ isAutomaticPushSubscriptionReportingEnabled: lifecyclePushEnabled,
121
121
  sessionConfiguration: RetenoSessionConfiguration(
122
122
  sessionDuration: sessionDurationSeconds,
123
123
  isSessionStartReportingEnabled: lifecycleSessionStartEnabled,
package/types/index.ts CHANGED
@@ -23,7 +23,7 @@ export type UserAttributesAnonymous = {
23
23
  * Market ID. Max 64 chars; latin letters, digits, '-' and '_' are allowed.
24
24
  * Pass `""` (empty string) to clear the value on the server.
25
25
  * Passing `null` or omitting the field has no effect (the existing value is kept).
26
- * Available on Android 2.9.5+ and iOS 2.7.1+.
26
+ * Available with the bundled Android 2.9.6+ and iOS 2.7.2+ SDKs.
27
27
  */
28
28
  marketId?: string;
29
29
  };
@@ -104,7 +104,7 @@ export type RetenoInitializeOptions = {
104
104
  inAppMessagesPauseBehaviour?: InAppPauseBehaviour;
105
105
  isAutomaticScreenReportingEnabled?: boolean;
106
106
  lifecycleTrackingOptions?: LifecycleTrackingOptions;
107
- /** Session reset duration in seconds (Android 2.9.4+, iOS 2.7.0+). */
107
+ /** Session reset duration in seconds (supported by the bundled Android 2.9.6+ and iOS 2.7.2+ SDKs). */
108
108
  sessionDurationSeconds?: number;
109
109
  /** Enables Reteno debug mode for near real-time event monitoring. Use only with test devices. */
110
110
  isDebugMode?: boolean;
@@ -154,6 +154,14 @@ export type NotificationChannelConfig = {
154
154
  description: string;
155
155
  };
156
156
 
157
+ /**
158
+ * Android notification grouping rule. The rule is persisted natively so it
159
+ * remains available when a push is handled before the WebView starts.
160
+ */
161
+ export type NotificationGroupingRule =
162
+ | { payloadKey: string; groupId?: never; showSummary?: boolean }
163
+ | { groupId: string; payloadKey?: never; showSummary?: boolean };
164
+
157
165
  export type AppInboxStatus = 'OPENED' | 'UNOPENED';
158
166
 
159
167
  export type AppInboxMessage = {
@@ -143,7 +143,7 @@ const RetenoPluginFunctions = {
143
143
  inAppMessagesPauseBehaviour?: 'SKIP_IN_APPS' | 'POSTPONE_IN_APPS',
144
144
  isAutomaticScreenReportingEnabled?: boolean,
145
145
  isDebugMode?: boolean,
146
- sessionDurationSeconds?: number, // Android 2.9.4+, iOS 2.7.0+
146
+ sessionDurationSeconds?: number, // Android 2.9.6+, iOS 2.7.2+
147
147
  lifecycleTrackingOptions?: {
148
148
  appLifecycleEnabled?: boolean,
149
149
  foregroundLifecycleEnabled?: boolean,
@@ -525,6 +525,44 @@ const RetenoPluginFunctions = {
525
525
  return __callWithAutoInit('updateDefaultNotificationChannel', [config], success, error);
526
526
  },
527
527
 
528
+ /*
529
+ config: NotificationGroupingRule | null
530
+ { payloadKey: string } groups by a value from the push payload.
531
+ { groupId: string } assigns every Reteno notification to a constant group.
532
+ Set showSummary: true to let the plugin maintain a group-summary notification.
533
+ Passing null disables grouping.
534
+ Android only.
535
+ */
536
+ setNotificationGroupingRule: function (arg0, success, error) {
537
+ // Accept either `config` or legacy `[config]`.
538
+ const config = Array.isArray(arg0) ? arg0[0] : arg0;
539
+ if (config === null) {
540
+ return __callWithExec('setNotificationGroupingRule', [null], success, error);
541
+ }
542
+ if (!config || typeof config !== 'object') {
543
+ return Promise.reject(
544
+ new Error('Invalid argument: expected null or an object with payloadKey or groupId')
545
+ );
546
+ }
547
+
548
+ const payloadKey = typeof config.payloadKey === 'string' ? config.payloadKey.trim() : '';
549
+ const groupId = typeof config.groupId === 'string' ? config.groupId.trim() : '';
550
+ if (config.showSummary !== undefined && typeof config.showSummary !== 'boolean') {
551
+ return Promise.reject(new Error('Invalid argument: showSummary must be a boolean'));
552
+ }
553
+ if (!!payloadKey === !!groupId) {
554
+ return Promise.reject(
555
+ new Error('Invalid argument: provide exactly one of payloadKey or groupId')
556
+ );
557
+ }
558
+
559
+ const normalizedConfig = payloadKey ? { payloadKey } : { groupId };
560
+ if (config.showSummary === true) {
561
+ normalizedConfig.showSummary = true;
562
+ }
563
+ return __callWithExec('setNotificationGroupingRule', [normalizedConfig], success, error);
564
+ },
565
+
528
566
  /*
529
567
  payload: GetAppInboxMessagesPayload
530
568
  {