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.
Files changed (53) hide show
  1. package/README.md +68 -2
  2. package/android/build.gradle +5 -0
  3. package/android/proguard-rules.pro +1 -0
  4. package/android/src/androidTest/java/app/notifee/core/DoScheduledWorkOrderingTest.java +234 -0
  5. package/android/src/androidTest/java/app/notifee/core/RebootRecoveryTest.java +204 -0
  6. package/android/src/androidTest/java/app/notifee/core/database/TimingWorkDataRepository.java +56 -0
  7. package/android/src/androidTest/java/app/notifee/core/database/WorkDataRepositoryRaceTest.java +221 -0
  8. package/android/src/androidTest/res/drawable/test_icon.xml +14 -0
  9. package/android/src/main/baseline-prof.txt +382 -0
  10. package/android/src/main/java/app/notifee/core/ForegroundService.java +269 -80
  11. package/android/src/main/java/app/notifee/core/InitProvider.java +48 -5
  12. package/android/src/main/java/app/notifee/core/Logger.java +5 -0
  13. package/android/src/main/java/app/notifee/core/NotifeeAlarmManager.java +160 -79
  14. package/android/src/main/java/app/notifee/core/NotificationManager.java +370 -288
  15. package/android/src/main/java/app/notifee/core/WarmupHelper.java +84 -0
  16. package/android/src/main/java/app/notifee/core/database/WorkDataRepository.java +38 -34
  17. package/android/src/main/java/app/notifee/core/model/NotificationAndroidModel.java +17 -1
  18. package/android/src/main/kotlin/io/invertase/notifee/NotifeeApiModule.kt +30 -0
  19. package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +182 -0
  20. package/android/src/test/java/app/notifee/core/database/WorkDataRepositoryFutureContractTest.java +279 -0
  21. package/dist/NotifeeApiModule.d.ts +1 -0
  22. package/dist/NotifeeApiModule.js +6 -0
  23. package/dist/NotifeeApiModule.js.map +1 -1
  24. package/dist/specs/NativeNotifeeModule.d.ts +1 -0
  25. package/dist/specs/NativeNotifeeModule.js.map +1 -1
  26. package/dist/types/Module.d.ts +19 -0
  27. package/dist/types/NotificationAndroid.d.ts +58 -0
  28. package/dist/types/NotificationAndroid.js +43 -0
  29. package/dist/types/NotificationAndroid.js.map +1 -1
  30. package/dist/utils/validate.d.ts +1 -0
  31. package/dist/utils/validate.js +10 -4
  32. package/dist/utils/validate.js.map +1 -1
  33. package/dist/validators/validateAndroidChannel.js +3 -3
  34. package/dist/validators/validateAndroidChannel.js.map +1 -1
  35. package/dist/validators/validateAndroidNotification.js +36 -12
  36. package/dist/validators/validateAndroidNotification.js.map +1 -1
  37. package/dist/version.d.ts +1 -1
  38. package/dist/version.js +1 -1
  39. package/ios/NotifeeCore/NotifeeCore+NSNotificationCenter.m +5 -1
  40. package/ios/NotifeeCore/NotifeeCore+UNUserNotificationCenter.m +5 -5
  41. package/ios/NotifeeCore/NotifeeCore.m +28 -9
  42. package/ios/NotifeeCore/NotifeeCoreDelegateHolder.m +5 -1
  43. package/ios/NotifeeCore/NotifeeCoreExtensionHelper.m +7 -2
  44. package/ios/RNNotifee/NotifeeApiModule.mm +31 -10
  45. package/package.json +1 -1
  46. package/src/NotifeeApiModule.ts +7 -0
  47. package/src/specs/NativeNotifeeModule.ts +1 -0
  48. package/src/types/Module.ts +20 -0
  49. package/src/types/NotificationAndroid.ts +62 -0
  50. package/src/utils/validate.ts +12 -4
  51. package/src/validators/validateAndroidChannel.ts +3 -3
  52. package/src/validators/validateAndroidNotification.ts +43 -10
  53. 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
- public void insert(WorkDataEntity workData) {
48
- NotifeeCoreDatabase.databaseWriteListeningExecutor.execute(
49
- () -> {
50
- mWorkDataDao.insert(workData);
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 NotifeeCoreDatabase.databaseWriteListeningExecutor.submit(
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 NotifeeCoreDatabase.databaseWriteListeningExecutor.submit(
61
- () -> mWorkDataDao.getAllWithAlarmManager(withAlarmManager));
74
+ return mExecutor.submit(() -> mWorkDataDao.getAllWithAlarmManager(withAlarmManager));
62
75
  }
63
76
 
64
77
  public ListenableFuture<List<WorkDataEntity>> getAll() {
65
- return NotifeeCoreDatabase.databaseWriteListeningExecutor.submit(() -> mWorkDataDao.getAll());
78
+ return mExecutor.submit(() -> mWorkDataDao.getAll());
66
79
  }
67
80
 
68
- public void deleteById(String id) {
69
- NotifeeCoreDatabase.databaseWriteListeningExecutor.execute(
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 void deleteByIds(List<String> ids) {
76
- NotifeeCoreDatabase.databaseWriteListeningExecutor.execute(
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 void deleteAll() {
83
- NotifeeCoreDatabase.databaseWriteListeningExecutor.execute(
84
- () -> {
85
- mWorkDataDao.deleteAll();
86
- });
89
+ public @NonNull ListenableFuture<Void> deleteAll() {
90
+ return submitWrite(() -> mWorkDataDao.deleteAll());
87
91
  }
88
92
 
89
- public static void insertTriggerNotification(
90
- NotificationModel notificationModel, Bundle triggerBundle, Boolean withAlarmManager) {
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
- mInstance.insert(workData);
105
+ return getInstance(context).insert(workData);
99
106
  }
100
107
 
101
- public void update(WorkDataEntity workData) {
102
- NotifeeCoreDatabase.databaseWriteListeningExecutor.execute(
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
+ }