react-native-notify-kit 10.8.1 → 10.9.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/README.md +33 -18
- package/android/build.gradle +50 -4
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/app/notifee/core/ForegroundService.java +395 -5
- package/android/src/main/java/app/notifee/core/ForegroundServiceRunLimitReceiver.java +15 -0
- package/android/src/main/java/app/notifee/core/ForegroundServiceRunLimitScheduler.java +461 -0
- package/android/src/main/java/app/notifee/core/NotificationManager.java +6 -0
- package/android/src/main/java/app/notifee/core/event/NotificationEvent.java +6 -0
- package/android/src/main/java/app/notifee/core/model/NotificationAndroidModel.java +29 -0
- package/android/src/main/kotlin/io/invertase/notifee/NotifeeEventSubscriber.kt +3 -0
- package/android/src/test/java/app/notifee/core/ForegroundServiceRunLimitTest.java +2357 -0
- package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +23 -13
- package/android/src/test/java/app/notifee/core/ShadowServiceCompatibilityTest.java +170 -0
- package/android/src/test/resources/robolectric.properties +4 -0
- package/cli/dist/cli.js +0 -1
- package/cli/dist/commands/initNse.js +1 -2
- package/cli/dist/lib/detectProject.js +1 -2
- package/cli/dist/lib/initNseCore.js +0 -1
- package/cli/dist/lib/logger.js +0 -1
- package/cli/dist/lib/patchPodfile.js +0 -1
- package/cli/dist/lib/patchXcodeProject.js +22 -3
- package/cli/dist/lib/writeTemplates.js +0 -1
- package/cli/dist/lib/xcode.js +6 -0
- package/cli/dist/templates/NotificationService.swift.tmpl +74 -6
- package/cli/package.json +1 -0
- package/cli/vendor/xcode/AUTHORS +6 -0
- package/cli/vendor/xcode/LICENSE +202 -0
- package/cli/vendor/xcode/NOTICE +5 -0
- package/cli/vendor/xcode/README.md +32 -0
- package/cli/vendor/xcode/UPSTREAM-SHA256.json +12 -0
- package/cli/vendor/xcode/index.js +18 -0
- package/cli/vendor/xcode/lib/parseJob.js +32 -0
- package/cli/vendor/xcode/lib/parser/pbxproj.js +1904 -0
- package/cli/vendor/xcode/lib/parser/pbxproj.pegjs +280 -0
- package/cli/vendor/xcode/lib/pbxFile.js +233 -0
- package/cli/vendor/xcode/lib/pbxProject.js +2205 -0
- package/cli/vendor/xcode/lib/pbxWriter.js +309 -0
- package/dist/NotifeeNativeModule.web.d.ts +2 -2
- package/dist/types/Notification.d.ts +12 -1
- package/dist/types/Notification.js +3 -0
- package/dist/types/Notification.js.map +1 -1
- package/dist/types/NotificationAndroid.d.ts +9 -0
- package/dist/types/NotificationAndroid.js.map +1 -1
- package/dist/validators/validateAndroidNotification.js +26 -1
- package/dist/validators/validateAndroidNotification.js.map +1 -1
- package/dist/validators/validateAndroidStyle.js +6 -0
- package/dist/validators/validateAndroidStyle.js.map +1 -1
- package/dist/validators/validateIOSAttachment.js +3 -1
- package/dist/validators/validateIOSAttachment.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +8 -5
- package/src/NotifeeNativeModule.web.ts +2 -2
- package/src/types/Notification.ts +13 -0
- package/src/types/NotificationAndroid.ts +10 -0
- package/src/validators/validateAndroidNotification.ts +31 -1
- package/src/validators/validateAndroidStyle.ts +10 -2
- package/src/validators/validateIOSAttachment.ts +5 -1
- package/src/version.ts +1 -1
- package/cli/dist/cli.bundle.js +0 -20834
- package/cli/dist/cli.d.ts +0 -2
- package/cli/dist/cli.js.map +0 -1
- package/cli/dist/commands/initNse.d.ts +0 -12
- package/cli/dist/commands/initNse.js.map +0 -1
- package/cli/dist/lib/detectProject.d.ts +0 -12
- package/cli/dist/lib/detectProject.js.map +0 -1
- package/cli/dist/lib/initNseCore.d.ts +0 -8
- package/cli/dist/lib/initNseCore.js.map +0 -1
- package/cli/dist/lib/logger.d.ts +0 -4
- package/cli/dist/lib/logger.js.map +0 -1
- package/cli/dist/lib/patchPodfile.d.ts +0 -23
- package/cli/dist/lib/patchPodfile.js.map +0 -1
- package/cli/dist/lib/patchXcodeProject.d.ts +0 -41
- package/cli/dist/lib/patchXcodeProject.js.map +0 -1
- package/cli/dist/lib/writeTemplates.d.ts +0 -11
- package/cli/dist/lib/writeTemplates.js.map +0 -1
- package/cli/dist/templates/templates/Info.plist.tmpl +0 -29
- package/cli/dist/templates/templates/NotificationService.swift.tmpl +0 -100
- package/cli/dist/templates/templates/NotifyKitNSE.entitlements.tmpl +0 -6
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
package app.notifee.core;
|
|
2
|
+
|
|
3
|
+
import android.app.AlarmManager;
|
|
4
|
+
import android.app.Application;
|
|
5
|
+
import android.app.PendingIntent;
|
|
6
|
+
import android.content.ComponentName;
|
|
7
|
+
import android.content.Context;
|
|
8
|
+
import android.content.Intent;
|
|
9
|
+
import android.content.SharedPreferences;
|
|
10
|
+
import android.content.pm.ActivityInfo;
|
|
11
|
+
import android.content.pm.ApplicationInfo;
|
|
12
|
+
import android.content.pm.PackageManager;
|
|
13
|
+
import android.content.pm.ServiceInfo;
|
|
14
|
+
import android.net.Uri;
|
|
15
|
+
import android.os.Build;
|
|
16
|
+
import android.os.SystemClock;
|
|
17
|
+
import android.provider.Settings;
|
|
18
|
+
import android.util.Log;
|
|
19
|
+
import java.util.HashSet;
|
|
20
|
+
import java.util.Set;
|
|
21
|
+
import java.util.UUID;
|
|
22
|
+
import org.json.JSONException;
|
|
23
|
+
import org.json.JSONObject;
|
|
24
|
+
|
|
25
|
+
/** Internal opt-in run limit. Persistence never grants lifecycle authority. */
|
|
26
|
+
final class ForegroundServiceRunLimitScheduler {
|
|
27
|
+
static final String ACTION = "app.notifee.core.FGS_RUN_LIMIT_EXPIRE";
|
|
28
|
+
static final String PROCESS = UUID.randomUUID().toString();
|
|
29
|
+
static final String PREFS = "notifee-fgs-run-limit";
|
|
30
|
+
static final int SCHEMA = 1;
|
|
31
|
+
// Index only new tickets. Never enumerate/adopt legacy or unindexed storage.
|
|
32
|
+
static final int GC_SLOTS = 32;
|
|
33
|
+
static final int GC_BATCH = 8;
|
|
34
|
+
private static final Object GC_LOCK = new Object();
|
|
35
|
+
private static final boolean[] RESERVED = new boolean[GC_SLOTS];
|
|
36
|
+
private static final Set<String> LIVE_KEYS = new HashSet<>();
|
|
37
|
+
private static int gcCursor;
|
|
38
|
+
private static boolean gcRunning;
|
|
39
|
+
|
|
40
|
+
static final class Request {
|
|
41
|
+
final String run;
|
|
42
|
+
final long duration;
|
|
43
|
+
final boolean publicOption;
|
|
44
|
+
|
|
45
|
+
private Request(long duration) {
|
|
46
|
+
this.run = UUID.randomUUID().toString();
|
|
47
|
+
this.duration = duration;
|
|
48
|
+
this.publicOption = true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
Request(Context context, String run, long duration) {
|
|
52
|
+
if (run == null || !run.matches("[a-zA-Z0-9_-]{1,80}") || duration < 1 || duration > 600000) {
|
|
53
|
+
throw new IllegalArgumentException(
|
|
54
|
+
"Explicit diagnostic run and duration (1..600000ms) required");
|
|
55
|
+
}
|
|
56
|
+
if (!available(context)) throw new IllegalStateException("Smoke debug receiver unavailable");
|
|
57
|
+
this.run = run;
|
|
58
|
+
this.duration = duration;
|
|
59
|
+
this.publicOption = false;
|
|
60
|
+
// Diagnostic fixture retains its reviewed admission-time sweep.
|
|
61
|
+
collectOrphans(context);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static Request publicRequest(Context context, long duration, int effectiveType) {
|
|
66
|
+
if (effectiveType != ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
|
67
|
+
|| !available(context, false)) {
|
|
68
|
+
throw new IllegalArgumentException(
|
|
69
|
+
"notification.android.foregroundServiceBestEffortStopAfterMs requires single dataSync and"
|
|
70
|
+
+ " an internal receiver in the app FGS process (API 35+)");
|
|
71
|
+
}
|
|
72
|
+
return new Request(duration);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static boolean available(Context context) {
|
|
76
|
+
return available(context, true);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private static boolean available(Context context, boolean diagnostic) {
|
|
80
|
+
if (context == null
|
|
81
|
+
|| Build.VERSION.SDK_INT < 35
|
|
82
|
+
|| (diagnostic
|
|
83
|
+
&& (context.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) == 0))
|
|
84
|
+
return false;
|
|
85
|
+
try {
|
|
86
|
+
ActivityInfo receiver =
|
|
87
|
+
context
|
|
88
|
+
.getPackageManager()
|
|
89
|
+
.getReceiverInfo(
|
|
90
|
+
new ComponentName(context, ForegroundServiceRunLimitReceiver.class), 0);
|
|
91
|
+
ServiceInfo service =
|
|
92
|
+
context
|
|
93
|
+
.getPackageManager()
|
|
94
|
+
.getServiceInfo(new ComponentName(context, ForegroundService.class), 0);
|
|
95
|
+
return (service.getForegroundServiceType() & ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
|
96
|
+
!= 0
|
|
97
|
+
&& receiver.enabled
|
|
98
|
+
&& !receiver.exported
|
|
99
|
+
&& context.getPackageName().equals(receiver.packageName)
|
|
100
|
+
&& java.util.Objects.equals(receiver.processName, service.processName)
|
|
101
|
+
&& (diagnostic || context.getApplicationInfo().processName.equals(service.processName));
|
|
102
|
+
} catch (PackageManager.NameNotFoundException | RuntimeException failure) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
static int boot(Context context) {
|
|
108
|
+
return Settings.Global.getInt(context.getContentResolver(), Settings.Global.BOOT_COUNT, -1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static final class Ticket {
|
|
112
|
+
final ForegroundService owner;
|
|
113
|
+
final Object epoch;
|
|
114
|
+
final long generation;
|
|
115
|
+
final long runGeneration;
|
|
116
|
+
final long deadline;
|
|
117
|
+
final long duration;
|
|
118
|
+
final boolean publicOption;
|
|
119
|
+
final int boot;
|
|
120
|
+
final String token = UUID.randomUUID().toString();
|
|
121
|
+
// Physical ownership: even a delayed/reentrant editor for A cannot address B's keys.
|
|
122
|
+
final String recordKey = "ticket/" + PROCESS + "/" + token;
|
|
123
|
+
final String stateKey = recordKey + "/state";
|
|
124
|
+
final String wire;
|
|
125
|
+
final Intent intent;
|
|
126
|
+
PendingIntent pendingIntent;
|
|
127
|
+
boolean valid = true;
|
|
128
|
+
boolean scheduled;
|
|
129
|
+
|
|
130
|
+
@androidx.annotation.RequiresApi(35)
|
|
131
|
+
Ticket(
|
|
132
|
+
ForegroundService owner,
|
|
133
|
+
Object epoch,
|
|
134
|
+
long generation,
|
|
135
|
+
long runGeneration,
|
|
136
|
+
Request request,
|
|
137
|
+
long promotedElapsed)
|
|
138
|
+
throws JSONException {
|
|
139
|
+
this.owner = owner;
|
|
140
|
+
this.epoch = epoch;
|
|
141
|
+
this.generation = generation;
|
|
142
|
+
this.runGeneration = runGeneration;
|
|
143
|
+
duration = request.duration;
|
|
144
|
+
publicOption = request.publicOption;
|
|
145
|
+
deadline = Math.addExact(promotedElapsed, request.duration);
|
|
146
|
+
boot = boot(owner);
|
|
147
|
+
if (boot < 0) throw new IllegalStateException("Boot identity unavailable");
|
|
148
|
+
wire =
|
|
149
|
+
new JSONObject()
|
|
150
|
+
.put("schema", SCHEMA)
|
|
151
|
+
.put("boot", boot)
|
|
152
|
+
.put("process", PROCESS)
|
|
153
|
+
.put("instance", owner.alarmPocInstanceId())
|
|
154
|
+
.put("run", request.run)
|
|
155
|
+
.put("epoch", UUID.randomUUID().toString())
|
|
156
|
+
.put("generation", generation)
|
|
157
|
+
.put("runGeneration", runGeneration)
|
|
158
|
+
.put("token", token)
|
|
159
|
+
.put("firstSuccessfulPromotionElapsed", promotedElapsed)
|
|
160
|
+
.put("deadlineElapsed", deadline)
|
|
161
|
+
.put("type", ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
|
162
|
+
.toString();
|
|
163
|
+
intent =
|
|
164
|
+
new Intent(owner, ForegroundServiceRunLimitReceiver.class)
|
|
165
|
+
.setAction(ACTION)
|
|
166
|
+
// Intent identity includes data; notification hashes and extras are never identity.
|
|
167
|
+
.setData(Uri.parse("notifykit-fgs-run-limit://expiry/" + PROCESS + "/" + token))
|
|
168
|
+
.putExtra("schema", SCHEMA)
|
|
169
|
+
.putExtra("token", token)
|
|
170
|
+
.putExtra("record", wire);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private static SharedPreferences ledger(Context context) {
|
|
175
|
+
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private static void write(Ticket ticket, String state, int slot) {
|
|
179
|
+
SharedPreferences.Editor editor = ledger(ticket.owner).edit();
|
|
180
|
+
if ("ARMING".equals(state)) editor.putString("gc/slot/" + slot, ticket.recordKey);
|
|
181
|
+
if (!editor
|
|
182
|
+
.putString(ticket.recordKey, ticket.wire)
|
|
183
|
+
.putString(ticket.stateKey, state)
|
|
184
|
+
.commit()) {
|
|
185
|
+
throw new IllegalStateException("Ledger commit refused");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
static boolean arm(Ticket ticket) {
|
|
190
|
+
String phase = "STORAGE_FAILED";
|
|
191
|
+
int slot = -1;
|
|
192
|
+
try {
|
|
193
|
+
synchronized (GC_LOCK) {
|
|
194
|
+
LIVE_KEYS.add(ticket.recordKey);
|
|
195
|
+
}
|
|
196
|
+
slot = reserveEmptySlot(ticket.owner);
|
|
197
|
+
write(ticket, "ARMING", slot);
|
|
198
|
+
if (!ticket.valid) return false;
|
|
199
|
+
phase = "SCHEDULE_FAILED";
|
|
200
|
+
ticket.pendingIntent =
|
|
201
|
+
PendingIntent.getBroadcast(ticket.owner, 0, ticket.intent, PendingIntent.FLAG_IMMUTABLE);
|
|
202
|
+
ticket
|
|
203
|
+
.owner
|
|
204
|
+
.getSystemService(AlarmManager.class)
|
|
205
|
+
.setAndAllowWhileIdle(
|
|
206
|
+
AlarmManager.ELAPSED_REALTIME_WAKEUP, ticket.deadline, ticket.pendingIntent);
|
|
207
|
+
if (!ticket.valid) return false;
|
|
208
|
+
phase = "STORAGE_FAILED";
|
|
209
|
+
write(ticket, "ARMED", slot);
|
|
210
|
+
if (!ticket.valid) return false;
|
|
211
|
+
ticket.scheduled = true;
|
|
212
|
+
marker("ALARM_ARMED", ticket, "requestedDurationIsVoluntary=true");
|
|
213
|
+
return true;
|
|
214
|
+
} catch (RuntimeException failure) {
|
|
215
|
+
marker(phase, ticket, failure.toString());
|
|
216
|
+
return false;
|
|
217
|
+
} finally {
|
|
218
|
+
if (slot >= 0) releaseSlot(slot);
|
|
219
|
+
if (!ticket.scheduled) cancel(ticket);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
static boolean matches(Ticket ticket, Intent intent) {
|
|
224
|
+
try {
|
|
225
|
+
return ticket.valid
|
|
226
|
+
&& ticket.scheduled
|
|
227
|
+
&& boot(ticket.owner) == ticket.boot
|
|
228
|
+
&& intent != null
|
|
229
|
+
&& ticket.intent.filterEquals(intent)
|
|
230
|
+
&& intent.getIntExtra("schema", -1) == SCHEMA
|
|
231
|
+
&& ticket.token.equals(intent.getStringExtra("token"))
|
|
232
|
+
&& ticket.wire.equals(intent.getStringExtra("record"));
|
|
233
|
+
} catch (RuntimeException malformed) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
static boolean ledgerMatches(Ticket ticket) {
|
|
239
|
+
try {
|
|
240
|
+
SharedPreferences prefs = ledger(ticket.owner);
|
|
241
|
+
return ticket.wire.equals(prefs.getString(ticket.recordKey, null))
|
|
242
|
+
&& "ARMED".equals(prefs.getString(ticket.stateKey, null));
|
|
243
|
+
} catch (RuntimeException failure) {
|
|
244
|
+
marker("STORAGE_FAILED", ticket, failure.toString());
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Caller has already revoked the live authority. Cancellation cannot revoke an in-flight send.
|
|
250
|
+
static void cancel(Ticket ticket) {
|
|
251
|
+
ticket.valid = false;
|
|
252
|
+
synchronized (GC_LOCK) {
|
|
253
|
+
LIVE_KEYS.remove(ticket.recordKey);
|
|
254
|
+
}
|
|
255
|
+
if (ticket.pendingIntent != null) {
|
|
256
|
+
try {
|
|
257
|
+
ticket.owner.getSystemService(AlarmManager.class).cancel(ticket.pendingIntent);
|
|
258
|
+
} catch (RuntimeException failure) {
|
|
259
|
+
marker("CANCEL_FAILED", ticket, failure.toString());
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
ticket.pendingIntent.cancel();
|
|
263
|
+
} catch (RuntimeException failure) {
|
|
264
|
+
marker("CANCEL_FAILED", ticket, failure.toString());
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
SharedPreferences prefs = ledger(ticket.owner);
|
|
269
|
+
// The comparison rejects corrupt records; it is not a CAS. Safety comes from the
|
|
270
|
+
// immutable ticket-specific keys at commit, including reentrancy inside remove/commit.
|
|
271
|
+
// Unindexed legacy/global orphans stay inert: never adopt or bulk-delete them.
|
|
272
|
+
if (ticket.wire.equals(prefs.getString(ticket.recordKey, null))
|
|
273
|
+
&& !prefs.edit().remove(ticket.recordKey).remove(ticket.stateKey).commit()) {
|
|
274
|
+
marker("STORAGE_FAILED", ticket, "cancellation commit refused");
|
|
275
|
+
}
|
|
276
|
+
} catch (RuntimeException failure) {
|
|
277
|
+
marker("STORAGE_FAILED", ticket, failure.toString());
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private static boolean reserveSlot(int slot) {
|
|
282
|
+
synchronized (GC_LOCK) {
|
|
283
|
+
if (RESERVED[slot]) return false;
|
|
284
|
+
RESERVED[slot] = true;
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private static void releaseSlot(int slot) {
|
|
290
|
+
synchronized (GC_LOCK) {
|
|
291
|
+
RESERVED[slot] = false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private static boolean liveKey(String key) {
|
|
296
|
+
synchronized (GC_LOCK) {
|
|
297
|
+
return LIVE_KEYS.contains(key);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private static final String UUID_PATTERN = "[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}";
|
|
302
|
+
|
|
303
|
+
private static boolean nonnegativeInteger(Object value) {
|
|
304
|
+
return (value instanceof Integer || value instanceof Long) && ((Number) value).longValue() >= 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private static boolean ticketKey(String key) {
|
|
308
|
+
return key != null && key.matches("ticket/" + UUID_PATTERN + "/" + UUID_PATTERN);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private static int reserveEmptySlot(Context context) {
|
|
312
|
+
SharedPreferences prefs = ledger(context);
|
|
313
|
+
for (int slot = 0; slot < GC_SLOTS; slot++) {
|
|
314
|
+
if (!reserveSlot(slot)) continue;
|
|
315
|
+
boolean claimed = false;
|
|
316
|
+
try {
|
|
317
|
+
String key = prefs.getString("gc/slot/" + slot, null);
|
|
318
|
+
if (key == null
|
|
319
|
+
|| (ticketKey(key)
|
|
320
|
+
&& !liveKey(key)
|
|
321
|
+
&& !prefs.contains(key)
|
|
322
|
+
&& !prefs.contains(key + "/state"))) {
|
|
323
|
+
claimed = true;
|
|
324
|
+
return slot;
|
|
325
|
+
}
|
|
326
|
+
} finally {
|
|
327
|
+
if (!claimed) releaseSlot(slot);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
marker("GC_CAPACITY", null, "32 slots occupied/ambiguous; diagnostic protection not armed");
|
|
331
|
+
throw new IllegalStateException("Diagnostic ledger index full; preserving existing records");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
@androidx.annotation.RequiresApi(35)
|
|
335
|
+
private static boolean orphanRecord(String key, String wire, String state) {
|
|
336
|
+
if (!ticketKey(key)
|
|
337
|
+
|| wire == null
|
|
338
|
+
|| wire.length() > 2048
|
|
339
|
+
|| !("ARMING".equals(state) || "ARMED".equals(state))) return false;
|
|
340
|
+
try {
|
|
341
|
+
JSONObject record = new JSONObject(wire);
|
|
342
|
+
String process = record.getString("process");
|
|
343
|
+
String token = record.getString("token");
|
|
344
|
+
return Integer.valueOf(SCHEMA).equals(record.get("schema"))
|
|
345
|
+
&& record.get("boot") instanceof Integer
|
|
346
|
+
&& record.getInt("boot") >= 0
|
|
347
|
+
&& key.equals("ticket/" + process + "/" + token)
|
|
348
|
+
&& record.getString("instance").matches(UUID_PATTERN)
|
|
349
|
+
&& record.getString("epoch").matches(UUID_PATTERN)
|
|
350
|
+
&& record.getString("run").matches("[a-zA-Z0-9_-]{1,80}")
|
|
351
|
+
&& nonnegativeInteger(record.get("generation"))
|
|
352
|
+
&& nonnegativeInteger(record.get("runGeneration"))
|
|
353
|
+
&& nonnegativeInteger(record.get("firstSuccessfulPromotionElapsed"))
|
|
354
|
+
&& nonnegativeInteger(record.get("deadlineElapsed"))
|
|
355
|
+
&& record.getLong("deadlineElapsed") > record.getLong("firstSuccessfulPromotionElapsed")
|
|
356
|
+
&& record.getLong("deadlineElapsed") - record.getLong("firstSuccessfulPromotionElapsed")
|
|
357
|
+
<= 18000000
|
|
358
|
+
&& Integer.valueOf(ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
|
359
|
+
.equals(record.get("type"))
|
|
360
|
+
// Another incarnation can never own a ticket here. For this incarnation, only
|
|
361
|
+
// absence from the native arm registry proves non-ownership; age is irrelevant.
|
|
362
|
+
&& (!PROCESS.equals(process) || !liveKey(key));
|
|
363
|
+
} catch (JSONException | RuntimeException ambiguous) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
static void collectOrphans(Context context) {
|
|
369
|
+
// A different configured Service process may still be alive. Its PROCESS UUID is not
|
|
370
|
+
// proof of a previous incarnation. Admission runs outside lifecycle locks, so verify
|
|
371
|
+
// the manifest process before touching storage. Unknown process identity keeps data.
|
|
372
|
+
try {
|
|
373
|
+
if (Build.VERSION.SDK_INT < 35
|
|
374
|
+
|| !Application.getProcessName()
|
|
375
|
+
.equals(
|
|
376
|
+
context
|
|
377
|
+
.getPackageManager()
|
|
378
|
+
.getServiceInfo(new ComponentName(context, ForegroundService.class), 0)
|
|
379
|
+
.processName)) {
|
|
380
|
+
marker("GC_RESULT", null, "examined=0 skipped=service_process_mismatch");
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
} catch (PackageManager.NameNotFoundException | RuntimeException unknown) {
|
|
384
|
+
marker("GC_RESULT", null, "examined=0 skipped=service_process_unknown");
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
int first;
|
|
388
|
+
synchronized (GC_LOCK) {
|
|
389
|
+
if (gcRunning) return;
|
|
390
|
+
gcRunning = true;
|
|
391
|
+
first = gcCursor;
|
|
392
|
+
gcCursor = (gcCursor + GC_BATCH) % GC_SLOTS;
|
|
393
|
+
}
|
|
394
|
+
int examined = 0, removed = 0, retained = 0, failures = 0;
|
|
395
|
+
try {
|
|
396
|
+
SharedPreferences prefs = ledger(context);
|
|
397
|
+
for (int offset = 0; offset < GC_BATCH; offset++) {
|
|
398
|
+
int slot = (first + offset) % GC_SLOTS;
|
|
399
|
+
examined++;
|
|
400
|
+
if (!reserveSlot(slot)) {
|
|
401
|
+
retained++;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
String key = prefs.getString("gc/slot/" + slot, null);
|
|
406
|
+
if (key == null) continue;
|
|
407
|
+
if (!ticketKey(key) || liveKey(key)) {
|
|
408
|
+
retained++;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
String wire = prefs.getString(key, null);
|
|
412
|
+
String state = prefs.getString(key + "/state", null);
|
|
413
|
+
if (wire == null && state == null) continue;
|
|
414
|
+
if (!orphanRecord(key, wire, state) || liveKey(key)) {
|
|
415
|
+
retained++;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
// This slot is reserved across all external calls. Neither its immutable victim
|
|
419
|
+
// address nor its index can be reassigned by a reentrant/new arm. Never remove
|
|
420
|
+
// the reusable index slot here: an old editor must only contain victim keys.
|
|
421
|
+
if (prefs.edit().remove(key).remove(key + "/state").commit()) removed++;
|
|
422
|
+
else failures++;
|
|
423
|
+
} catch (RuntimeException failure) {
|
|
424
|
+
failures++;
|
|
425
|
+
} finally {
|
|
426
|
+
releaseSlot(slot);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
} catch (RuntimeException failure) {
|
|
430
|
+
failures++;
|
|
431
|
+
} finally {
|
|
432
|
+
synchronized (GC_LOCK) {
|
|
433
|
+
gcRunning = false;
|
|
434
|
+
}
|
|
435
|
+
marker(
|
|
436
|
+
"GC_RESULT",
|
|
437
|
+
null,
|
|
438
|
+
"examined="
|
|
439
|
+
+ examined
|
|
440
|
+
+ " removed="
|
|
441
|
+
+ removed
|
|
442
|
+
+ " retained="
|
|
443
|
+
+ retained
|
|
444
|
+
+ " failures="
|
|
445
|
+
+ failures
|
|
446
|
+
+ " unindexed=unknown");
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
static void marker(String event, Ticket ticket, String detail) {
|
|
451
|
+
// android.util.Log only: no EventBus, ReactContext, JS event or headless task.
|
|
452
|
+
Log.i(
|
|
453
|
+
"ForegroundServiceRunLimit",
|
|
454
|
+
event
|
|
455
|
+
+ " elapsed="
|
|
456
|
+
+ SystemClock.elapsedRealtime()
|
|
457
|
+
+ (ticket == null ? "" : " record=" + ticket.wire)
|
|
458
|
+
+ " detail="
|
|
459
|
+
+ detail);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
@@ -662,6 +662,12 @@ class NotificationManager {
|
|
|
662
662
|
|
|
663
663
|
static ListenableFuture<Void> displayNotification(
|
|
664
664
|
NotificationModel notificationModel, Bundle triggerBundle) {
|
|
665
|
+
// Reject invalid native/bridge opt-in before dispatching any START or posting a notification.
|
|
666
|
+
try {
|
|
667
|
+
notificationModel.getAndroid().getForegroundServiceBestEffortStopAfterMs();
|
|
668
|
+
} catch (RuntimeException invalidOption) {
|
|
669
|
+
return Futures.immediateFailedFuture(invalidOption);
|
|
670
|
+
}
|
|
665
671
|
return new ExtendedListenableFuture<>(notificationBundleToBuilder(notificationModel))
|
|
666
672
|
.continueWith(
|
|
667
673
|
(taskResult) -> {
|
|
@@ -41,6 +41,8 @@ public class NotificationEvent {
|
|
|
41
41
|
|
|
42
42
|
@KeepForSdk public static final int TYPE_FG_TIMEOUT = 9;
|
|
43
43
|
|
|
44
|
+
@KeepForSdk public static final int TYPE_FG_RUN_LIMIT_REACHED = 10;
|
|
45
|
+
|
|
44
46
|
private final int type;
|
|
45
47
|
private final Bundle extras;
|
|
46
48
|
private final NotificationModel notification;
|
|
@@ -85,6 +87,10 @@ public class NotificationEvent {
|
|
|
85
87
|
eventDetailMap.put("input", input);
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
Bundle runLimit = extras.getBundle("foregroundServiceRunLimit");
|
|
91
|
+
if (runLimit != null)
|
|
92
|
+
eventDetailMap.put("foregroundServiceRunLimit", ObjectUtils.bundleToMap(runLimit));
|
|
93
|
+
|
|
88
94
|
if (extras.containsKey("startId")) {
|
|
89
95
|
eventDetailMap.put("startId", extras.getInt("startId"));
|
|
90
96
|
}
|
|
@@ -98,6 +98,35 @@ public class NotificationAndroidModel {
|
|
|
98
98
|
return foregroundServiceType;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** Validates bridge input too; never truncate a JS double or silently clamp overflow. */
|
|
102
|
+
@Nullable
|
|
103
|
+
public Long getForegroundServiceBestEffortStopAfterMs() {
|
|
104
|
+
String key = "foregroundServiceBestEffortStopAfterMs";
|
|
105
|
+
if (!mNotificationAndroidBundle.containsKey(key)) return null;
|
|
106
|
+
Object raw = mNotificationAndroidBundle.get(key);
|
|
107
|
+
String error = "notification.android." + key;
|
|
108
|
+
if (!(raw instanceof Number)) throw new IllegalArgumentException(error + " must be a number");
|
|
109
|
+
double value = ((Number) raw).doubleValue();
|
|
110
|
+
if (!Double.isFinite(value) || value != Math.rint(value) || value < 60000 || value > 18000000) {
|
|
111
|
+
throw new IllegalArgumentException(error + " must be an integer in 60000..18000000ms");
|
|
112
|
+
}
|
|
113
|
+
if (!getAsForegroundService() || Build.VERSION.SDK_INT < 35) {
|
|
114
|
+
throw new IllegalArgumentException(error + " requires asForegroundService and API 35+");
|
|
115
|
+
}
|
|
116
|
+
int type = getForegroundServiceType();
|
|
117
|
+
if (type != ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST
|
|
118
|
+
&& type != ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) {
|
|
119
|
+
throw new IllegalArgumentException(error + " requires single dataSync");
|
|
120
|
+
}
|
|
121
|
+
long duration = ((Number) raw).longValue();
|
|
122
|
+
try {
|
|
123
|
+
Math.addExact(android.os.SystemClock.elapsedRealtime(), duration);
|
|
124
|
+
} catch (ArithmeticException failure) {
|
|
125
|
+
throw new IllegalArgumentException(error + " deadline overflow", failure);
|
|
126
|
+
}
|
|
127
|
+
return duration;
|
|
128
|
+
}
|
|
129
|
+
|
|
101
130
|
/**
|
|
102
131
|
* Gets whether the notification is for a foreground service
|
|
103
132
|
*
|
|
@@ -45,6 +45,9 @@ class NotifeeEventSubscriber : EventListener {
|
|
|
45
45
|
extras.getString(KEY_DETAIL_INPUT)?.let { input ->
|
|
46
46
|
eventDetailMap.putString(KEY_DETAIL_INPUT, input)
|
|
47
47
|
}
|
|
48
|
+
extras.getBundle("foregroundServiceRunLimit")?.let { runLimit ->
|
|
49
|
+
eventDetailMap.putMap("foregroundServiceRunLimit", Arguments.fromBundle(runLimit))
|
|
50
|
+
}
|
|
48
51
|
if (extras.containsKey("startId")) {
|
|
49
52
|
eventDetailMap.putInt("startId", extras.getInt("startId"))
|
|
50
53
|
}
|