react-native-notify-kit 9.3.0 → 9.5.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 +68 -2
- package/android/build.gradle +5 -0
- package/android/proguard-rules.pro +1 -0
- package/android/src/androidTest/java/app/notifee/core/DoScheduledWorkOrderingTest.java +234 -0
- package/android/src/androidTest/java/app/notifee/core/RebootRecoveryTest.java +204 -0
- package/android/src/androidTest/java/app/notifee/core/database/TimingWorkDataRepository.java +56 -0
- package/android/src/androidTest/java/app/notifee/core/database/WorkDataRepositoryRaceTest.java +221 -0
- package/android/src/androidTest/res/drawable/test_icon.xml +14 -0
- package/android/src/main/baseline-prof.txt +382 -0
- package/android/src/main/java/app/notifee/core/ForegroundService.java +269 -80
- package/android/src/main/java/app/notifee/core/InitProvider.java +48 -5
- package/android/src/main/java/app/notifee/core/Logger.java +5 -0
- package/android/src/main/java/app/notifee/core/NotifeeAlarmManager.java +160 -79
- package/android/src/main/java/app/notifee/core/NotificationManager.java +370 -288
- package/android/src/main/java/app/notifee/core/WarmupHelper.java +84 -0
- package/android/src/main/java/app/notifee/core/database/WorkDataRepository.java +38 -34
- package/android/src/main/java/app/notifee/core/model/NotificationAndroidModel.java +17 -1
- package/android/src/main/kotlin/io/invertase/notifee/NotifeeApiModule.kt +30 -0
- package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +182 -0
- package/android/src/test/java/app/notifee/core/database/WorkDataRepositoryFutureContractTest.java +279 -0
- package/dist/NotifeeApiModule.d.ts +1 -0
- package/dist/NotifeeApiModule.js +6 -0
- package/dist/NotifeeApiModule.js.map +1 -1
- package/dist/specs/NativeNotifeeModule.d.ts +1 -0
- package/dist/specs/NativeNotifeeModule.js.map +1 -1
- package/dist/types/Module.d.ts +19 -0
- package/dist/types/NotificationAndroid.d.ts +58 -0
- package/dist/types/NotificationAndroid.js +43 -0
- package/dist/types/NotificationAndroid.js.map +1 -1
- package/dist/utils/validate.d.ts +1 -0
- package/dist/utils/validate.js +10 -4
- package/dist/utils/validate.js.map +1 -1
- package/dist/validators/validateAndroidChannel.js +3 -3
- package/dist/validators/validateAndroidChannel.js.map +1 -1
- package/dist/validators/validateAndroidNotification.js +36 -12
- package/dist/validators/validateAndroidNotification.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/ios/NotifeeCore/NotifeeCore+NSNotificationCenter.m +5 -1
- package/ios/NotifeeCore/NotifeeCore+UNUserNotificationCenter.m +5 -5
- package/ios/NotifeeCore/NotifeeCore.m +28 -9
- package/ios/NotifeeCore/NotifeeCoreDelegateHolder.m +5 -1
- package/ios/NotifeeCore/NotifeeCoreExtensionHelper.m +7 -2
- package/ios/RNNotifee/NotifeeApiModule.mm +31 -10
- package/package.json +1 -1
- package/src/NotifeeApiModule.ts +7 -0
- package/src/specs/NativeNotifeeModule.ts +1 -0
- package/src/types/Module.ts +20 -0
- package/src/types/NotificationAndroid.ts +62 -0
- package/src/utils/validate.ts +12 -4
- package/src/validators/validateAndroidChannel.ts +3 -3
- package/src/validators/validateAndroidNotification.ts +43 -10
- package/src/version.ts +1 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
package app.notifee.core;
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Copyright (c) 2016-present Invertase Limited & Contributors
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this library except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import android.content.Context;
|
|
21
|
+
import android.os.Trace;
|
|
22
|
+
import androidx.core.app.NotificationManagerCompat;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Encapsulates the foreground-service warmup logic so it can be called from both {@link
|
|
26
|
+
* InitProvider} (automatic, at app startup) and the JS bridge ({@code prewarmForegroundService()},
|
|
27
|
+
* on-demand).
|
|
28
|
+
*
|
|
29
|
+
* <p>This class runs <b>synchronously</b> on the caller's thread. Callers are responsible for
|
|
30
|
+
* choosing the appropriate thread/executor.
|
|
31
|
+
*
|
|
32
|
+
* <p>Safe to call multiple times (idempotent). After the first call, class loading is a no-op from
|
|
33
|
+
* ART's perspective and the Binder proxy call is cheap.
|
|
34
|
+
*/
|
|
35
|
+
@KeepForSdk
|
|
36
|
+
public final class WarmupHelper {
|
|
37
|
+
|
|
38
|
+
private static final String TAG = "WarmupHelper";
|
|
39
|
+
|
|
40
|
+
static final String[] WARMUP_CLASSES = {
|
|
41
|
+
"app.notifee.core.ForegroundService",
|
|
42
|
+
"app.notifee.core.NotificationManager",
|
|
43
|
+
"app.notifee.core.model.NotificationModel",
|
|
44
|
+
"app.notifee.core.model.NotificationAndroidModel",
|
|
45
|
+
"app.notifee.core.model.ChannelModel",
|
|
46
|
+
"androidx.core.app.NotificationCompat$Builder",
|
|
47
|
+
"androidx.core.app.NotificationManagerCompat",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
private WarmupHelper() {}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pre-loads critical foreground-service classes and warms the INotificationManager Binder proxy.
|
|
54
|
+
* Runs synchronously on the caller's thread.
|
|
55
|
+
*
|
|
56
|
+
* @param context Application context used for class loading and Binder warmup.
|
|
57
|
+
*/
|
|
58
|
+
@KeepForSdk
|
|
59
|
+
public static void runWarmup(Context context) {
|
|
60
|
+
Trace.beginSection("notifee:warmup");
|
|
61
|
+
try {
|
|
62
|
+
ClassLoader classLoader = context.getClassLoader();
|
|
63
|
+
|
|
64
|
+
// Pre-load critical foreground service classes to move ART class loading/verification
|
|
65
|
+
// cost from the first displayNotification() call to whenever the caller chooses.
|
|
66
|
+
for (String className : WARMUP_CLASSES) {
|
|
67
|
+
try {
|
|
68
|
+
Class.forName(className, true, classLoader);
|
|
69
|
+
} catch (ClassNotFoundException e) {
|
|
70
|
+
Logger.d(TAG, "Warmup class not found: " + className);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Pre-warm INotificationManager Binder proxy by touching NotificationManagerCompat.
|
|
75
|
+
try {
|
|
76
|
+
NotificationManagerCompat.from(context).getNotificationChannels();
|
|
77
|
+
} catch (Exception e) {
|
|
78
|
+
Logger.d(TAG, "Warmup Binder pre-warm failed: " + e.getMessage());
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
Trace.endSection();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -20,13 +20,16 @@ package app.notifee.core.database;
|
|
|
20
20
|
import android.content.Context;
|
|
21
21
|
import android.os.Bundle;
|
|
22
22
|
import androidx.annotation.NonNull;
|
|
23
|
+
import androidx.annotation.VisibleForTesting;
|
|
23
24
|
import app.notifee.core.model.NotificationModel;
|
|
24
25
|
import app.notifee.core.utility.ObjectUtils;
|
|
25
26
|
import com.google.common.util.concurrent.ListenableFuture;
|
|
27
|
+
import com.google.common.util.concurrent.ListeningExecutorService;
|
|
26
28
|
import java.util.List;
|
|
27
29
|
|
|
28
30
|
public class WorkDataRepository {
|
|
29
|
-
private WorkDataDao mWorkDataDao;
|
|
31
|
+
private final WorkDataDao mWorkDataDao;
|
|
32
|
+
private final ListeningExecutorService mExecutor;
|
|
30
33
|
private static WorkDataRepository mInstance;
|
|
31
34
|
|
|
32
35
|
public static @NonNull WorkDataRepository getInstance(@NonNull Context context) {
|
|
@@ -42,52 +45,56 @@ public class WorkDataRepository {
|
|
|
42
45
|
public WorkDataRepository(Context context) {
|
|
43
46
|
NotifeeCoreDatabase db = NotifeeCoreDatabase.getDatabase(context);
|
|
44
47
|
mWorkDataDao = db.workDao();
|
|
48
|
+
mExecutor = NotifeeCoreDatabase.databaseWriteListeningExecutor;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
@VisibleForTesting
|
|
52
|
+
WorkDataRepository(@NonNull WorkDataDao dao, @NonNull ListeningExecutorService executor) {
|
|
53
|
+
mWorkDataDao = dao;
|
|
54
|
+
mExecutor = executor;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Submit a DAO write to the listening executor and return a future that
|
|
58
|
+
// completes when the DAO call returns (success or exception). Using the
|
|
59
|
+
// submit(Runnable, result) overload avoids the Callable<Object> inference
|
|
60
|
+
// that would otherwise require a cast at every call site.
|
|
61
|
+
private @NonNull ListenableFuture<Void> submitWrite(@NonNull Runnable work) {
|
|
62
|
+
return mExecutor.submit(work, (Void) null);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public @NonNull ListenableFuture<Void> insert(WorkDataEntity workData) {
|
|
66
|
+
return submitWrite(() -> mWorkDataDao.insert(workData));
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
public ListenableFuture<WorkDataEntity> getWorkDataById(String id) {
|
|
55
|
-
return
|
|
56
|
-
() -> mWorkDataDao.getWorkDataById(id));
|
|
70
|
+
return mExecutor.submit(() -> mWorkDataDao.getWorkDataById(id));
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
public ListenableFuture<List<WorkDataEntity>> getAllWithAlarmManager(Boolean withAlarmManager) {
|
|
60
|
-
return
|
|
61
|
-
() -> mWorkDataDao.getAllWithAlarmManager(withAlarmManager));
|
|
74
|
+
return mExecutor.submit(() -> mWorkDataDao.getAllWithAlarmManager(withAlarmManager));
|
|
62
75
|
}
|
|
63
76
|
|
|
64
77
|
public ListenableFuture<List<WorkDataEntity>> getAll() {
|
|
65
|
-
return
|
|
78
|
+
return mExecutor.submit(() -> mWorkDataDao.getAll());
|
|
66
79
|
}
|
|
67
80
|
|
|
68
|
-
public
|
|
69
|
-
|
|
70
|
-
() -> {
|
|
71
|
-
mWorkDataDao.deleteById(id);
|
|
72
|
-
});
|
|
81
|
+
public @NonNull ListenableFuture<Void> deleteById(String id) {
|
|
82
|
+
return submitWrite(() -> mWorkDataDao.deleteById(id));
|
|
73
83
|
}
|
|
74
84
|
|
|
75
|
-
public
|
|
76
|
-
|
|
77
|
-
() -> {
|
|
78
|
-
mWorkDataDao.deleteByIds(ids);
|
|
79
|
-
});
|
|
85
|
+
public @NonNull ListenableFuture<Void> deleteByIds(List<String> ids) {
|
|
86
|
+
return submitWrite(() -> mWorkDataDao.deleteByIds(ids));
|
|
80
87
|
}
|
|
81
88
|
|
|
82
|
-
public
|
|
83
|
-
|
|
84
|
-
() -> {
|
|
85
|
-
mWorkDataDao.deleteAll();
|
|
86
|
-
});
|
|
89
|
+
public @NonNull ListenableFuture<Void> deleteAll() {
|
|
90
|
+
return submitWrite(() -> mWorkDataDao.deleteAll());
|
|
87
91
|
}
|
|
88
92
|
|
|
89
|
-
public static
|
|
90
|
-
|
|
93
|
+
public static @NonNull ListenableFuture<Void> insertTriggerNotification(
|
|
94
|
+
@NonNull Context context,
|
|
95
|
+
NotificationModel notificationModel,
|
|
96
|
+
Bundle triggerBundle,
|
|
97
|
+
Boolean withAlarmManager) {
|
|
91
98
|
WorkDataEntity workData =
|
|
92
99
|
new WorkDataEntity(
|
|
93
100
|
notificationModel.getId(),
|
|
@@ -95,13 +102,10 @@ public class WorkDataRepository {
|
|
|
95
102
|
ObjectUtils.bundleToBytes(triggerBundle),
|
|
96
103
|
withAlarmManager);
|
|
97
104
|
|
|
98
|
-
|
|
105
|
+
return getInstance(context).insert(workData);
|
|
99
106
|
}
|
|
100
107
|
|
|
101
|
-
public
|
|
102
|
-
|
|
103
|
-
() -> {
|
|
104
|
-
mWorkDataDao.update(workData);
|
|
105
|
-
});
|
|
108
|
+
public @NonNull ListenableFuture<Void> update(WorkDataEntity workData) {
|
|
109
|
+
return submitWrite(() -> mWorkDataDao.update(workData));
|
|
106
110
|
}
|
|
107
111
|
}
|
|
@@ -74,6 +74,11 @@ public class NotificationAndroidModel {
|
|
|
74
74
|
Objects.requireNonNull(
|
|
75
75
|
mNotificationAndroidBundle.getParcelableArrayList("foregroundServiceTypes"));
|
|
76
76
|
|
|
77
|
+
if (foregroundServiceTypesArrayList.isEmpty()) {
|
|
78
|
+
Logger.w(TAG, "foregroundServiceTypes is empty; treating as absent");
|
|
79
|
+
return ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST;
|
|
80
|
+
}
|
|
81
|
+
|
|
77
82
|
int foregroundServiceType = 0;
|
|
78
83
|
for (int i = 0; i < foregroundServiceTypesArrayList.size(); i++) {
|
|
79
84
|
foregroundServiceType |= ObjectUtils.getInt(foregroundServiceTypesArrayList.get(i));
|
|
@@ -97,6 +102,17 @@ public class NotificationAndroidModel {
|
|
|
97
102
|
return mNotificationAndroidBundle.getBoolean("asForegroundService", false);
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Gets the foreground service behavior for the notification. Controls whether the foreground
|
|
107
|
+
* service notification is shown immediately or deferred on Android 12+.
|
|
108
|
+
*
|
|
109
|
+
* @return int matching a NotificationCompat.FOREGROUND_SERVICE_* constant
|
|
110
|
+
*/
|
|
111
|
+
public int getForegroundServiceBehavior() {
|
|
112
|
+
return mNotificationAndroidBundle.getInt(
|
|
113
|
+
"foregroundServiceBehavior", NotificationCompat.FOREGROUND_SERVICE_DEFAULT);
|
|
114
|
+
}
|
|
115
|
+
|
|
100
116
|
/**
|
|
101
117
|
* Gets if the notification should light up the screen when displayed
|
|
102
118
|
*
|
|
@@ -328,7 +344,7 @@ public class NotificationAndroidModel {
|
|
|
328
344
|
|
|
329
345
|
return lights;
|
|
330
346
|
} catch (Exception e) {
|
|
331
|
-
Logger.e(TAG, "getLights -> Failed to parse lights");
|
|
347
|
+
Logger.e(TAG, "getLights -> Failed to parse lights", e);
|
|
332
348
|
return null;
|
|
333
349
|
}
|
|
334
350
|
}
|
|
@@ -317,6 +317,36 @@ class NotifeeApiModule(reactContext: ReactApplicationContext) :
|
|
|
317
317
|
}
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
override fun prewarmForegroundService(promise: Promise) {
|
|
321
|
+
val context = reactApplicationContext.applicationContext ?: run {
|
|
322
|
+
promise.reject("ERR_CONTEXT", "ReactApplicationContext is null")
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
val executor = java.util.concurrent.Executors.newSingleThreadExecutor { r ->
|
|
326
|
+
Thread(r, "notifee-prewarm").apply {
|
|
327
|
+
isDaemon = true
|
|
328
|
+
priority = Thread.MIN_PRIORITY
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
executor.submit {
|
|
332
|
+
android.os.Trace.beginSection("notifee:prewarm")
|
|
333
|
+
try {
|
|
334
|
+
app.notifee.core.WarmupHelper.runWarmup(context)
|
|
335
|
+
promise.resolve(null)
|
|
336
|
+
} catch (t: Throwable) {
|
|
337
|
+
// Best-effort semantics: warmup failures don't reject the promise.
|
|
338
|
+
// WarmupHelper logs and swallows internal errors; if something
|
|
339
|
+
// unexpectedly escapes, log it and still resolve so the JS-side
|
|
340
|
+
// await does not hang.
|
|
341
|
+
Logger.e("NotifeeApiModule", "prewarmForegroundService unexpectedly threw", t)
|
|
342
|
+
promise.resolve(null)
|
|
343
|
+
} finally {
|
|
344
|
+
android.os.Trace.endSection()
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
executor.shutdown()
|
|
348
|
+
}
|
|
349
|
+
|
|
320
350
|
override fun hideNotificationDrawer() {
|
|
321
351
|
NotifeeReactUtils.hideNotificationDrawer()
|
|
322
352
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
package app.notifee.core;
|
|
2
|
+
|
|
3
|
+
import static org.junit.Assert.assertEquals;
|
|
4
|
+
import static org.junit.Assert.assertTrue;
|
|
5
|
+
import static org.junit.Assert.fail;
|
|
6
|
+
|
|
7
|
+
import android.app.Service;
|
|
8
|
+
import android.content.Intent;
|
|
9
|
+
import org.junit.After;
|
|
10
|
+
import org.junit.Before;
|
|
11
|
+
import org.junit.Test;
|
|
12
|
+
import org.junit.runner.RunWith;
|
|
13
|
+
import org.robolectric.Robolectric;
|
|
14
|
+
import org.robolectric.RobolectricTestRunner;
|
|
15
|
+
import org.robolectric.RuntimeEnvironment;
|
|
16
|
+
import org.robolectric.android.controller.ServiceController;
|
|
17
|
+
import org.robolectric.annotation.Config;
|
|
18
|
+
|
|
19
|
+
@RunWith(RobolectricTestRunner.class)
|
|
20
|
+
public class ForegroundServiceTest {
|
|
21
|
+
|
|
22
|
+
@Before
|
|
23
|
+
public void setUp() {
|
|
24
|
+
// Initialize ContextHolder so the service can access the application context
|
|
25
|
+
ContextHolder.setApplicationContext(RuntimeEnvironment.getApplication());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@After
|
|
29
|
+
public void tearDown() {
|
|
30
|
+
// Reset all accessible static fields to prevent cross-test pollution.
|
|
31
|
+
// mCurrentNotificationBundle, mCurrentNotification, mCurrentHashCode are private static
|
|
32
|
+
// and can only be reset via onStartCommand (the STOP path clears them).
|
|
33
|
+
ForegroundService.mCurrentNotificationId = null;
|
|
34
|
+
ForegroundService.mCurrentForegroundServiceType = -1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Regression test for Bug #1: a STOP intent arriving on a fresh service instance that has never
|
|
39
|
+
* called startForeground() must not crash. The defensive startForeground() path should fire,
|
|
40
|
+
* satisfying Android's contract, and the service should stop cleanly.
|
|
41
|
+
*/
|
|
42
|
+
@Test
|
|
43
|
+
public void onStartCommand_stopIntentBeforeStart_doesNotCrash() {
|
|
44
|
+
ServiceController<ForegroundService> controller =
|
|
45
|
+
Robolectric.buildService(ForegroundService.class);
|
|
46
|
+
ForegroundService service = controller.create().get();
|
|
47
|
+
|
|
48
|
+
Intent stopIntent = new Intent();
|
|
49
|
+
stopIntent.setAction(ForegroundService.STOP_FOREGROUND_SERVICE_ACTION);
|
|
50
|
+
|
|
51
|
+
// This should not throw — the defensive startForeground() path handles the case
|
|
52
|
+
int result = service.onStartCommand(stopIntent, 0, 1);
|
|
53
|
+
assertEquals(Service.START_STICKY_COMPATIBILITY, result);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Regression test: a null intent (service recreation after process kill) must not crash. Android
|
|
58
|
+
* may deliver a null intent when recreating a service.
|
|
59
|
+
*/
|
|
60
|
+
@Test
|
|
61
|
+
public void onStartCommand_nullIntent_doesNotCrash() {
|
|
62
|
+
ServiceController<ForegroundService> controller =
|
|
63
|
+
Robolectric.buildService(ForegroundService.class);
|
|
64
|
+
ForegroundService service = controller.create().get();
|
|
65
|
+
|
|
66
|
+
// Null intent simulates service recreation after process kill
|
|
67
|
+
int result = service.onStartCommand(null, 0, 1);
|
|
68
|
+
assertEquals(Service.START_STICKY_COMPATIBILITY, result);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Verifies that after the STOP path, the public static state fields are reset to their initial
|
|
73
|
+
* values. Only checks the two public static fields (mCurrentNotificationId and
|
|
74
|
+
* mCurrentForegroundServiceType) — the three private static fields (mCurrentNotificationBundle,
|
|
75
|
+
* mCurrentNotification, mCurrentHashCode) are not directly accessible from tests.
|
|
76
|
+
*/
|
|
77
|
+
@Test
|
|
78
|
+
public void onStartCommand_stopIntent_resetsPublicStaticState() {
|
|
79
|
+
ServiceController<ForegroundService> controller =
|
|
80
|
+
Robolectric.buildService(ForegroundService.class);
|
|
81
|
+
ForegroundService service = controller.create().get();
|
|
82
|
+
|
|
83
|
+
// Set some stale state to simulate a prior invocation
|
|
84
|
+
ForegroundService.mCurrentNotificationId = "test-id";
|
|
85
|
+
ForegroundService.mCurrentForegroundServiceType = 42;
|
|
86
|
+
|
|
87
|
+
Intent stopIntent = new Intent();
|
|
88
|
+
stopIntent.setAction(ForegroundService.STOP_FOREGROUND_SERVICE_ACTION);
|
|
89
|
+
|
|
90
|
+
service.onStartCommand(stopIntent, 0, 1);
|
|
91
|
+
|
|
92
|
+
// Public static state should be reset
|
|
93
|
+
assertEquals(null, ForegroundService.mCurrentNotificationId);
|
|
94
|
+
assertEquals(-1, ForegroundService.mCurrentForegroundServiceType);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Bug A regression: on API 34+ with no foregroundServiceType declared in the manifest, the
|
|
99
|
+
* defensive STOP path must throw a RuntimeException (causing a crash with an actionable message)
|
|
100
|
+
* instead of silently catching and proceeding to stopSelf() — which would leave Android's
|
|
101
|
+
* 5-second startForeground() contract unsatisfied and result in a cryptic ANR.
|
|
102
|
+
*/
|
|
103
|
+
@Test(expected = RuntimeException.class)
|
|
104
|
+
@Config(sdk = 34)
|
|
105
|
+
public void onStartCommand_stopIntentApi34NoManifestType_throwsRuntimeException() {
|
|
106
|
+
// Robolectric's default shadow PackageManager returns FOREGROUND_SERVICE_TYPE_NONE (0)
|
|
107
|
+
// for services without an explicit foregroundServiceType in the test manifest.
|
|
108
|
+
ServiceController<ForegroundService> controller =
|
|
109
|
+
Robolectric.buildService(ForegroundService.class);
|
|
110
|
+
ForegroundService service = controller.create().get();
|
|
111
|
+
|
|
112
|
+
Intent stopIntent = new Intent();
|
|
113
|
+
stopIntent.setAction(ForegroundService.STOP_FOREGROUND_SERVICE_ACTION);
|
|
114
|
+
service.onStartCommand(stopIntent, 0, 1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Bug A regression: the RuntimeException message must contain the documentation URL so the
|
|
119
|
+
* developer debugging the crash can find the fix immediately.
|
|
120
|
+
*/
|
|
121
|
+
@Test
|
|
122
|
+
@Config(sdk = 34)
|
|
123
|
+
public void onStartCommand_stopIntentApi34NoManifestType_messageContainsDocUrl() {
|
|
124
|
+
ServiceController<ForegroundService> controller =
|
|
125
|
+
Robolectric.buildService(ForegroundService.class);
|
|
126
|
+
ForegroundService service = controller.create().get();
|
|
127
|
+
|
|
128
|
+
Intent stopIntent = new Intent();
|
|
129
|
+
stopIntent.setAction(ForegroundService.STOP_FOREGROUND_SERVICE_ACTION);
|
|
130
|
+
try {
|
|
131
|
+
service.onStartCommand(stopIntent, 0, 1);
|
|
132
|
+
fail("Expected RuntimeException");
|
|
133
|
+
} catch (RuntimeException e) {
|
|
134
|
+
assertTrue(
|
|
135
|
+
"Message should contain documentation URL",
|
|
136
|
+
e.getMessage().contains("foreground-service-setup-android-14"));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Backward compatibility: on API levels below 34, the defensive path should run normally without
|
|
142
|
+
* the proactive manifest check. No foregroundServiceType declaration is required pre-API 34.
|
|
143
|
+
*/
|
|
144
|
+
@Test
|
|
145
|
+
@Config(sdk = 33)
|
|
146
|
+
public void onStartCommand_stopIntentApiBelow34_defensivePathRunsNormally() {
|
|
147
|
+
ServiceController<ForegroundService> controller =
|
|
148
|
+
Robolectric.buildService(ForegroundService.class);
|
|
149
|
+
ForegroundService service = controller.create().get();
|
|
150
|
+
|
|
151
|
+
Intent stopIntent = new Intent();
|
|
152
|
+
stopIntent.setAction(ForegroundService.STOP_FOREGROUND_SERVICE_ACTION);
|
|
153
|
+
|
|
154
|
+
// Should not throw on API 33, regardless of manifest
|
|
155
|
+
int result = service.onStartCommand(stopIntent, 0, 1);
|
|
156
|
+
assertEquals(Service.START_STICKY_COMPATIBILITY, result);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Idempotency: when mStartForegroundCalled is already true (the service has previously called
|
|
161
|
+
* startForeground() successfully), the helper must be a no-op. Verified by sending two
|
|
162
|
+
* consecutive STOP intents — the second should not crash even on API 33 where the first succeeded
|
|
163
|
+
* via the defensive path.
|
|
164
|
+
*/
|
|
165
|
+
@Test
|
|
166
|
+
@Config(sdk = 33)
|
|
167
|
+
public void onStartCommand_stopIntentAfterSuccessfulStart_skipsDefensivePath() {
|
|
168
|
+
ServiceController<ForegroundService> controller =
|
|
169
|
+
Robolectric.buildService(ForegroundService.class);
|
|
170
|
+
ForegroundService service = controller.create().get();
|
|
171
|
+
|
|
172
|
+
Intent stopIntent = new Intent();
|
|
173
|
+
stopIntent.setAction(ForegroundService.STOP_FOREGROUND_SERVICE_ACTION);
|
|
174
|
+
|
|
175
|
+
// First STOP — triggers defensive startForeground
|
|
176
|
+
service.onStartCommand(stopIntent, 0, 1);
|
|
177
|
+
|
|
178
|
+
// Second STOP — helper should be no-op since mStartForegroundCalled is now true
|
|
179
|
+
int result = service.onStartCommand(stopIntent, 0, 2);
|
|
180
|
+
assertEquals(Service.START_STICKY_COMPATIBILITY, result);
|
|
181
|
+
}
|
|
182
|
+
}
|