react-native-icon-switcher 0.1.6 → 0.1.8

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 CHANGED
@@ -22,7 +22,7 @@ Switch the app icon at runtime on React Native and Expo from one config file, wi
22
22
  npx expo install react-native-icon-switcher
23
23
  ```
24
24
 
25
- Add the config plugin in `app.json` / `app.config.ts` (icon paths can live here or in `icon-switcher.config.ts` at the project root — see [Config](#config)):
25
+ Add the config plugin in `app.json` / `app.config.ts` and pass the icon config as its options (same shape as [Config](#config)):
26
26
 
27
27
  ```ts
28
28
  export default {
@@ -44,6 +44,20 @@ export default {
44
44
 
45
45
  Then run `npx expo prebuild` or use a development build. Expo Go is not supported.
46
46
 
47
+ The plugin only reads its inline options; it does not look for `icon-switcher.config.ts` on its own. To keep the config in its own file (for example to share it with the `sync` CLI), import it in `app.config.ts` with the explicit extension:
48
+
49
+ ```ts
50
+ import iconSwitcherConfig from './icon-switcher.config.ts';
51
+
52
+ export default {
53
+ expo: {
54
+ plugins: [['react-native-icon-switcher', iconSwitcherConfig]],
55
+ },
56
+ };
57
+ ```
58
+
59
+ Expo transpiles `app.config.ts` but not the files it imports, so this relies on Node's built-in TypeScript type stripping (Node 22.18 or later). On older Node versions, write the config as `icon-switcher.config.js` instead.
60
+
47
61
  ### Bare React Native
48
62
 
49
63
  ```sh
@@ -61,13 +75,27 @@ npx react-native-icon-switcher sync
61
75
 
62
76
  Use `npx react-native-icon-switcher sync --check` in CI to fail when generated native files or `icon-switcher.d.ts` are out of date.
63
77
 
78
+ ```sh
79
+ react-native-icon-switcher sync [--check] [--project-root <dir>] [--config <path>]
80
+ ```
81
+
82
+ | Flag | Default | Description |
83
+ | --- | --- | --- |
84
+ | `--check` | off | Write nothing; print each out-of-date path and exit `1` if any. |
85
+ | `--project-root <dir>` | current directory | App root containing `ios/` and `android/` (useful in monorepos). |
86
+ | `--config <path>` | first of `icon-switcher.config.{ts,js,mjs,cjs}` in the project root | Config file to load. TypeScript is loaded directly, with no compile step. |
87
+
88
+ Exit codes: `0` success (or no drift with `--check`), `1` drift found by `--check`, `2` invalid arguments, config errors, or an unexpected project layout. Without `--check` it prints `updated N files, unchanged M`.
89
+
64
90
  **No `expo` package is required** for bare apps: this library is a TurboModule with a standalone `sync` CLI. You only need `expo` if you opt into the Expo config plugin workflow.
65
91
 
66
92
  See `example/` (Expo) and `example-bare/` (bare CLI + `sync`) in this repo.
67
93
 
68
94
  ## Config
69
95
 
70
- Bare apps use `icon-switcher.config.ts` at the project root. Expo apps can pass the same shape as plugin options or point the plugin at a config file.
96
+ Bare apps use `icon-switcher.config.ts` at the project root. Expo apps pass the same shape as plugin options (or import that file, see [Expo](#expo)).
97
+
98
+ `defineIcons` has no runtime effect: it returns its argument unchanged and exists so TypeScript checks the object as you write it (misspelled keys, wrong `applyOn` values, a missing adaptive `background`) and offers autocomplete. Value rules are checked when `sync` or `expo prebuild` runs, and every problem is reported at once.
71
99
 
72
100
  ```ts
73
101
  import { defineIcons } from 'react-native-icon-switcher/config';
@@ -105,12 +133,31 @@ Per-icon `ios` / `android` fields accept a path or structured variants (`IosIcon
105
133
 
106
134
  Icons with no art for a platform are omitted from that platform's generated outputs.
107
135
 
136
+ **Validation:** `sync` and the Expo plugin fail if `icons` is empty, a name does not match `/^[a-z][a-z0-9_]*$/` or uses `default`, a colour is not `#RGB` / `#RRGGBB`, an image file does not exist, or Expo's default icon and `default` point at different files.
137
+
108
138
  **Android reserved names:** names may not be `round`, `foreground`, `background`, or `monochrome`, nor end in `_round`, `_foreground`, `_background`, or `_monochrome`.
109
139
 
140
+ **Android adaptive icons:** layers are 108×108 dp, but launchers show only the central 72×72 dp and then mask it (circle, squircle, …). Keep logos and text inside that centre, about the middle two thirds of the image. To fit artwork that fills its square, scale it into the centre of the `background` image on a matching colour and use a transparent `foreground`.
141
+
110
142
  **Legacy Android icons (API 24–25):** besides the adaptive layers and `mipmap-anydpi-v26` XML, `sync` and the plugin write `ic_launcher*.png` the way Android Studio's Image Asset tool does: the visible centre of an adaptive icon (72 dp of the 108 dp layers; a plain path uses the whole image) in a 38 dp rounded square or a 44 dp circle, with a soft drop shadow, on a transparent 48 dp canvas.
111
143
 
144
+ **Variants (experimental):** iOS `dark` / `tinted` and Android `monochrome` (themed icons, Android 13+) are generated when set, but have not yet been verified on devices.
145
+
146
+ ### What `sync` and the plugin generate
147
+
148
+ | | iOS | Android |
149
+ | --- | --- | --- |
150
+ | Images | `AppIcon-<name>.appiconset` in `Images.xcassets` (1024 px, flattened onto `ios.flattenOn`) | `mipmap-*/ic_launcher_<name>*` in five densities, plus `mipmap-anydpi-v26` adaptive XML |
151
+ | Registration | `ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES` and `ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS` build settings | One `activity-alias` per icon, `.IconSwitcher_<name>`; `MainActivity` loses its launcher filter |
152
+ | Runtime list | `IconSwitcherIcons` array in `Info.plist` | `com.iconswitcher.*` meta-data in `AndroidManifest.xml` |
153
+ | Types | `icon-switcher.d.ts` at the project root | same file |
154
+
155
+ Xcode generates `CFBundleIcons` from the build settings, so the library does not write it. Other content in these files is left as it is: comments, formatting, and the quoting CocoaPods applies to the pbxproj, so `sync --check` stays clean after `pod install`. In bare apps, commit the generated files. With Expo prebuild, commit `icon-switcher.d.ts`; native folders follow your usual prebuild policy.
156
+
112
157
  **Managed iOS icon sets:** when `sync` or the plugin rewrites an `.appiconset` it owns, it deletes files in that folder that the new `Contents.json` does not reference (`sync --check` reports them). Other icon sets are left alone.
113
158
 
159
+ **Generated images across machines:** image resampling rounds slightly differently on different CPUs (for example an Apple silicon Mac and x86 Linux CI). `sync` treats an existing PNG whose pixels differ from the regenerated one by at most 3 levels per channel as unchanged, so `sync --check` stays green and files are not rewritten.
160
+
114
161
  ## Usage
115
162
 
116
163
  ```ts
@@ -133,7 +180,7 @@ if (IconSwitcher.isSupported) {
133
180
 
134
181
  | Method | Returns | Notes |
135
182
  | --- | --- | --- |
136
- | `IconSwitcher.isSupported` | `boolean` | `false` when the TurboModule is missing (web, Expo Go, unlink) or the device brand is skipped. |
183
+ | `IconSwitcher.isSupported` | `boolean` | `false` when the TurboModule is missing (web, Expo Go, unlink), the device brand is skipped, or iOS reports `supportsAlternateIcons` as `false`. |
137
184
  | `IconSwitcher.list()` | `IconName[]` | Bundled icon names; `default` first. |
138
185
  | `IconSwitcher.getCurrent()` | `Promise<IconName>` | |
139
186
  | `IconSwitcher.set(name)` | `Promise<{ status: 'applied' \| 'pending' }>` | Android with `applyOn: 'background'` → `pending` until background or `applyPending()`. |
@@ -181,7 +228,7 @@ const unsubscribe = await syncIcon({
181
228
  const res = await fetch('https://api.example.com/icon');
182
229
  return (await res.json()).iconName as string;
183
230
  },
184
- storage: optionalAsyncKeyValue, // default: in-memory
231
+ storage: AsyncStorage, // optional; default: in-memory
185
232
  retryAfterMs: 24 * 60 * 60 * 1000, // skip re-apply after failure
186
233
  onError: (err) => console.warn(err),
187
234
  });
@@ -192,6 +239,10 @@ const unsubscribe = await syncIcon({
192
239
 
193
240
  On **iOS**, the helper runs when the app is `active` and subscribes to `AppState`. On **Android**, it runs once when called (`set` may still return `pending`).
194
241
 
242
+ Each run does nothing when `fetchChoice` returns `null`, `undefined`, `''`, or a name not in `list()`, or when the choice is already current. A `pending` result is not recorded as applied.
243
+
244
+ `storage` is any object with async `getItem(key): Promise<string | null>` and `setItem(key, value): Promise<void>` (`SyncIconStorage`), such as `@react-native-async-storage/async-storage`. The helper uses the keys `icon-switcher:lastApplied` and `icon-switcher:lastFailed`. With the default in-memory storage, the retry block lasts only as long as that `syncIcon` call, so a restart retries straight away.
245
+
195
246
  ## Testing with Jest
196
247
 
197
248
  Ship a CommonJS mock that never loads native code:
@@ -242,11 +293,12 @@ it('applies a pending switch', async () => {
242
293
 
243
294
  Deferred apply, task recreation when aliases toggle, the `applyPending()` foreground guard, cold-start behaviour, and launcher shortcuts are documented in [docs/guides/android.md](docs/guides/android.md).
244
295
 
245
- Summary: with default `applyOn: 'background'`, `set()` returns `pending` and the launcher updates after the user leaves the app; the process usually survives but the next open may look like a cold start. `applyPending()` in the foreground is refused with `foreground`.
296
+ Summary: with default `applyOn: 'background'`, `set()` returns `pending` and the launcher updates when the last screen stops (including swipe-away from Recents) or after you background the app; listening starts at process start via AndroidX Startup. The process usually survives but the next open may look like a cold start. `applyPending()` in the foreground is refused with `foreground`.
246
297
 
247
298
  ## Requirements
248
299
 
249
300
  - **New Architecture (TurboModule) required** — React Native **≥ 0.82** (developed with **0.88**).
301
+ - **iOS 15.1+** (React Native's `min_ios_version_supported`) and **Android minSdk 24**. On Android the module adds `androidx.lifecycle:lifecycle-process` to detect when the app goes to the background.
250
302
  - **Expo SDK ≥ 52** when using the config plugin (optional peer; developed with **SDK 58**).
251
303
  - **Expo Go is not supported** — use a development build or bare native project.
252
304
 
@@ -43,6 +43,7 @@ android {
43
43
  dependencies {
44
44
  implementation "com.facebook.react:react-android"
45
45
  implementation "androidx.lifecycle:lifecycle-process:2.8.7"
46
+ implementation "androidx.startup:startup-runtime:1.2.0"
46
47
 
47
48
  testImplementation "androidx.test:core:1.7.0"
48
49
  testImplementation "junit:junit:4.13.2"
@@ -1,2 +1,16 @@
1
- <manifest xmlns:android="http://schemas.android.com/apk/res/android">
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
2
+ xmlns:tools="http://schemas.android.com/tools">
3
+
4
+ <application>
5
+ <provider
6
+ android:name="androidx.startup.InitializationProvider"
7
+ android:authorities="${applicationId}.androidx-startup"
8
+ android:exported="false"
9
+ tools:node="merge">
10
+ <meta-data
11
+ android:name="com.iconswitcher.IconSwitcherInitializer"
12
+ android:value="androidx.startup"
13
+ tools:node="merge" />
14
+ </provider>
15
+ </application>
2
16
  </manifest>
@@ -1,5 +1,7 @@
1
1
  package com.iconswitcher
2
2
 
3
+ import android.app.Activity
4
+ import android.app.Application
3
5
  import android.content.ComponentName
4
6
  import android.content.Context
5
7
  import android.content.pm.ApplicationInfo
@@ -7,6 +9,7 @@ import android.content.pm.PackageManager
7
9
  import android.os.Build
8
10
  import android.os.Handler
9
11
  import android.os.Looper
12
+ import androidx.annotation.VisibleForTesting
10
13
  import androidx.lifecycle.DefaultLifecycleObserver
11
14
  import androidx.lifecycle.Lifecycle
12
15
  import androidx.lifecycle.LifecycleOwner
@@ -25,9 +28,13 @@ class IconSwitcher internal constructor(
25
28
  private val packageName: String,
26
29
  private val prefs: android.content.SharedPreferences,
27
30
  private val lifecycleRegistrar: (DefaultLifecycleObserver) -> Unit,
31
+ private val activityLifecycleRegistrar: (Application.ActivityLifecycleCallbacks) -> Unit,
28
32
  private val isInForeground: () -> Boolean,
29
33
  ) {
30
34
  private val writeExecutor = Executors.newSingleThreadExecutor()
35
+ private val processStartLock = Any()
36
+ private var processStartInitialized = false
37
+ private var startedActivityCount = 0
31
38
 
32
39
  private val markedAliases: Map<String, ComponentName> by lazy { discoverMarkedAliases() }
33
40
  private val applyOn: String by lazy { readApplyOn() }
@@ -53,6 +60,9 @@ class IconSwitcher internal constructor(
53
60
  }
54
61
  }
55
62
  },
63
+ activityLifecycleRegistrar = { callbacks ->
64
+ (context.applicationContext as Application).registerActivityLifecycleCallbacks(callbacks)
65
+ },
56
66
  isInForeground = {
57
67
  ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
58
68
  },
@@ -67,21 +77,33 @@ class IconSwitcher internal constructor(
67
77
  packageName = context.applicationContext.packageName,
68
78
  prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE),
69
79
  lifecycleRegistrar = { },
80
+ activityLifecycleRegistrar = { },
70
81
  isInForeground = { false },
71
82
  )
72
83
 
73
84
  fun onModuleCreate() {
74
- lifecycleRegistrar(
75
- object : DefaultLifecycleObserver {
76
- override fun onStop(owner: LifecycleOwner) {
77
- val pending = prefs.getString(KEY_PENDING, null)
78
- if (pending != null) {
79
- runApply(pending, deferred = true)
85
+ onProcessStart()
86
+ }
87
+
88
+ fun onProcessStart() {
89
+ synchronized(processStartLock) {
90
+ if (processStartInitialized) {
91
+ return
92
+ }
93
+ activityLifecycleRegistrar(createActivityLifecycleCallbacks())
94
+ lifecycleRegistrar(
95
+ object : DefaultLifecycleObserver {
96
+ override fun onStop(owner: LifecycleOwner) {
97
+ val pending = prefs.getString(KEY_PENDING, null)
98
+ if (pending != null) {
99
+ runApply(pending, deferred = true)
100
+ }
80
101
  }
81
- }
82
- },
83
- )
84
- runColdStart()
102
+ },
103
+ )
104
+ runColdStart()
105
+ processStartInitialized = true
106
+ }
85
107
  }
86
108
 
87
109
  fun current(): String {
@@ -126,6 +148,41 @@ class IconSwitcher internal constructor(
126
148
  return SetResult(STATUS_APPLIED)
127
149
  }
128
150
 
151
+ private fun createActivityLifecycleCallbacks(): Application.ActivityLifecycleCallbacks {
152
+ return object : Application.ActivityLifecycleCallbacks {
153
+ override fun onActivityStarted(activity: Activity) {
154
+ startedActivityCount++
155
+ }
156
+
157
+ override fun onActivityStopped(activity: Activity) {
158
+ // Clamped: when registered late (no startup provider, so from the module),
159
+ // activities that started earlier were never counted.
160
+ startedActivityCount = maxOf(0, startedActivityCount - 1)
161
+ if (startedActivityCount == 0 && !activity.isChangingConfigurations) {
162
+ val pending = prefs.getString(KEY_PENDING, null)
163
+ if (pending != null) {
164
+ applyPendingOnLastActivityStop(pending)
165
+ }
166
+ }
167
+ }
168
+
169
+ override fun onActivityCreated(activity: Activity, savedInstanceState: android.os.Bundle?) {}
170
+ override fun onActivityResumed(activity: Activity) {}
171
+ override fun onActivityPaused(activity: Activity) {}
172
+ override fun onActivitySaveInstanceState(activity: Activity, outState: android.os.Bundle) {}
173
+ override fun onActivityDestroyed(activity: Activity) {}
174
+ }
175
+ }
176
+
177
+ private fun applyPendingOnLastActivityStop(name: String) {
178
+ val future = submitOnWriteExecutor { applyOnExecutor(name, deferred = true) }
179
+ try {
180
+ future.get()
181
+ } catch (_: Exception) {
182
+ // applyOnExecutor stores lastError for deferred failures; never propagate here.
183
+ }
184
+ }
185
+
129
186
  private fun runColdStart() {
130
187
  if (prefs.getString(KEY_PENDING, null) != null) {
131
188
  return
@@ -376,5 +433,23 @@ class IconSwitcher internal constructor(
376
433
  const val STATUS_APPLIED = "applied"
377
434
  const val STATUS_PENDING = "pending"
378
435
  const val STATUS_NONE = "none"
436
+
437
+ @Volatile
438
+ private var instance: IconSwitcher? = null
439
+
440
+ fun getInstance(context: Context): IconSwitcher {
441
+ val appContext = context.applicationContext
442
+ instance?.let { return it }
443
+ return synchronized(this) {
444
+ instance ?: IconSwitcher(appContext).also { instance = it }
445
+ }
446
+ }
447
+
448
+ @VisibleForTesting
449
+ internal fun clearInstanceForTests() {
450
+ synchronized(this) {
451
+ instance = null
452
+ }
453
+ }
379
454
  }
380
455
  }
@@ -0,0 +1,15 @@
1
+ package com.iconswitcher
2
+
3
+ import android.content.Context
4
+ import androidx.lifecycle.ProcessLifecycleInitializer
5
+ import androidx.startup.Initializer
6
+
7
+ class IconSwitcherInitializer : Initializer<Unit> {
8
+ override fun create(context: Context) {
9
+ IconSwitcher.getInstance(context).onProcessStart()
10
+ }
11
+
12
+ override fun dependencies(): List<Class<out Initializer<*>>> {
13
+ return listOf(ProcessLifecycleInitializer::class.java)
14
+ }
15
+ }
@@ -8,7 +8,7 @@ import com.facebook.react.bridge.WritableMap
8
8
  class IconSwitcherModule(reactContext: ReactApplicationContext) :
9
9
  NativeIconSwitcherSpec(reactContext) {
10
10
 
11
- private val switcher = IconSwitcher(reactApplicationContext)
11
+ private val switcher = IconSwitcher.getInstance(reactApplicationContext)
12
12
 
13
13
  override fun initialize() {
14
14
  super.initialize()
@@ -1,5 +1,7 @@
1
1
  package com.iconswitcher
2
2
 
3
+ import android.app.Activity
4
+ import android.app.Application
3
5
  import android.content.ComponentName
4
6
  import android.content.Context
5
7
  import android.content.pm.ActivityInfo
@@ -34,6 +36,7 @@ class IconSwitcherTest {
34
36
 
35
37
  @Before
36
38
  fun setUp() {
39
+ IconSwitcher.clearInstanceForTests()
37
40
  context = ApplicationProvider.getApplicationContext()
38
41
  context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE)
39
42
  .edit()
@@ -205,6 +208,7 @@ class IconSwitcherTest {
205
208
  packageName = packageName,
206
209
  prefs = prefs,
207
210
  lifecycleRegistrar = { lifecycleObserver = it },
211
+ activityLifecycleRegistrar = { },
208
212
  isInForeground = { false },
209
213
  )
210
214
 
@@ -331,6 +335,231 @@ class IconSwitcherTest {
331
335
  ShadowLooper.idleMainLooper()
332
336
  }
333
337
 
338
+ @Test
339
+ fun initializer_starts_singleton_without_react_module() {
340
+ IconSwitcherInitializer().create(context)
341
+ val fromInitializer = IconSwitcher.getInstance(context)
342
+ assertTrue(fromInitializer.isSupported)
343
+ }
344
+
345
+ @Test
346
+ fun on_process_start_twice_registers_activity_callbacks_once() {
347
+ var registrationCount = 0
348
+ val pm = mockk<PackageManager>(relaxed = true)
349
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(context.packageName)
350
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo()
351
+ val switcher = switcherWithActivityCallbacks(
352
+ pm = pm,
353
+ onRegister = { registrationCount++ },
354
+ )
355
+ switcher.onProcessStart()
356
+ switcher.onProcessStart()
357
+ assertEquals(1, registrationCount)
358
+ }
359
+
360
+ @Test
361
+ fun getInstance_is_shared_between_initializer_calls() {
362
+ IconSwitcherInitializer().create(context)
363
+ val first = IconSwitcher.getInstance(context)
364
+ val second = IconSwitcher.getInstance(context)
365
+ assertTrue(first === second)
366
+ }
367
+
368
+ @Test
369
+ fun last_activity_stop_applies_pending_before_on_activity_stopped_returns() {
370
+ val pm = mockk<PackageManager>(relaxed = true)
371
+ val packageName = context.packageName
372
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(packageName)
373
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo()
374
+ val settingsSlot = slot<List<PackageManager.ComponentEnabledSetting>>()
375
+ every { pm.setComponentEnabledSettings(capture(settingsSlot)) } returns Unit
376
+ every { pm.getComponentEnabledSetting(any()) } answers {
377
+ val component = firstArg<ComponentName>()
378
+ if (component.className == ".IconSwitcher_halloween") {
379
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED
380
+ } else {
381
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED
382
+ }
383
+ }
384
+
385
+ val prefs = context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE)
386
+ prefs.edit().putString(IconSwitcher.KEY_PENDING, "halloween").apply()
387
+
388
+ var callbacks: Application.ActivityLifecycleCallbacks? = null
389
+ val switcher = switcherWithActivityCallbacks(pm) { callbacks = it }
390
+ switcher.onProcessStart()
391
+
392
+ val activity = mockk<Activity>(relaxed = true)
393
+ every { activity.isChangingConfigurations } returns false
394
+ callbacks!!.onActivityStarted(activity)
395
+ callbacks!!.onActivityStopped(activity)
396
+
397
+ assertTrue(
398
+ settingsSlot.captured.any {
399
+ it.componentName!!.className == ".IconSwitcher_halloween" &&
400
+ it.enabledFlags == PackageManager.DONT_KILL_APP
401
+ },
402
+ )
403
+ assertEquals(null, prefs.getString(IconSwitcher.KEY_PENDING, null))
404
+ assertEquals("halloween", prefs.getString(IconSwitcher.KEY_CURRENT, null))
405
+ }
406
+
407
+ @Test
408
+ fun last_activity_stop_applies_when_callbacks_were_registered_after_it_started() {
409
+ // No startup provider: listening starts from the module while a screen is
410
+ // already open, so its start was never counted.
411
+ val pm = mockk<PackageManager>(relaxed = true)
412
+ val packageName = context.packageName
413
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(packageName)
414
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo()
415
+ val settingsSlot = slot<List<PackageManager.ComponentEnabledSetting>>()
416
+ every { pm.setComponentEnabledSettings(capture(settingsSlot)) } returns Unit
417
+ every { pm.getComponentEnabledSetting(any()) } answers {
418
+ val component = firstArg<ComponentName>()
419
+ if (component.className == ".IconSwitcher_halloween") {
420
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED
421
+ } else {
422
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED
423
+ }
424
+ }
425
+
426
+ val prefs = context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE)
427
+ prefs.edit().putString(IconSwitcher.KEY_PENDING, "halloween").apply()
428
+
429
+ var callbacks: Application.ActivityLifecycleCallbacks? = null
430
+ val switcher = switcherWithActivityCallbacks(pm) { callbacks = it }
431
+ switcher.onProcessStart()
432
+
433
+ val activity = mockk<Activity>(relaxed = true)
434
+ every { activity.isChangingConfigurations } returns false
435
+ callbacks!!.onActivityStopped(activity)
436
+
437
+ assertTrue(settingsSlot.isCaptured)
438
+ assertEquals(null, prefs.getString(IconSwitcher.KEY_PENDING, null))
439
+ assertEquals("halloween", prefs.getString(IconSwitcher.KEY_CURRENT, null))
440
+ }
441
+
442
+ @Test
443
+ fun last_activity_stop_with_two_activities_applies_only_after_both_stop() {
444
+ val pm = mockk<PackageManager>(relaxed = true)
445
+ val packageName = context.packageName
446
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(packageName)
447
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo()
448
+
449
+ val prefs = context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE)
450
+ prefs.edit().putString(IconSwitcher.KEY_PENDING, "halloween").apply()
451
+
452
+ var callbacks: Application.ActivityLifecycleCallbacks? = null
453
+ val switcher = switcherWithActivityCallbacks(pm) { callbacks = it }
454
+ switcher.onProcessStart()
455
+
456
+ val first = mockk<Activity>(relaxed = true)
457
+ val second = mockk<Activity>(relaxed = true)
458
+ every { first.isChangingConfigurations } returns false
459
+ every { second.isChangingConfigurations } returns false
460
+
461
+ callbacks!!.onActivityStarted(first)
462
+ callbacks!!.onActivityStarted(second)
463
+ callbacks!!.onActivityStopped(second)
464
+ verify(exactly = 0) { pm.setComponentEnabledSettings(any()) }
465
+
466
+ every { pm.getComponentEnabledSetting(any()) } answers {
467
+ val component = firstArg<ComponentName>()
468
+ if (component.className == ".IconSwitcher_halloween") {
469
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED
470
+ } else {
471
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED
472
+ }
473
+ }
474
+ every { pm.setComponentEnabledSettings(any<List<PackageManager.ComponentEnabledSetting>>()) } returns Unit
475
+
476
+ callbacks!!.onActivityStopped(first)
477
+ verify(exactly = 1) { pm.setComponentEnabledSettings(any()) }
478
+ }
479
+
480
+ @Test
481
+ fun last_activity_stop_during_configuration_change_does_not_apply() {
482
+ val pm = mockk<PackageManager>(relaxed = true)
483
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(context.packageName)
484
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo()
485
+
486
+ context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE)
487
+ .edit()
488
+ .putString(IconSwitcher.KEY_PENDING, "halloween")
489
+ .apply()
490
+
491
+ var callbacks: Application.ActivityLifecycleCallbacks? = null
492
+ val switcher = switcherWithActivityCallbacks(pm) { callbacks = it }
493
+ switcher.onProcessStart()
494
+
495
+ val activity = mockk<Activity>(relaxed = true)
496
+ every { activity.isChangingConfigurations } returns true
497
+ callbacks!!.onActivityStarted(activity)
498
+ callbacks!!.onActivityStopped(activity)
499
+
500
+ verify(exactly = 0) { pm.setComponentEnabledSettings(any()) }
501
+ }
502
+
503
+ @Test
504
+ fun last_activity_stop_without_pending_does_nothing() {
505
+ val pm = mockk<PackageManager>(relaxed = true)
506
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(context.packageName)
507
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo()
508
+
509
+ var callbacks: Application.ActivityLifecycleCallbacks? = null
510
+ val switcher = switcherWithActivityCallbacks(pm) { callbacks = it }
511
+ switcher.onProcessStart()
512
+
513
+ val activity = mockk<Activity>(relaxed = true)
514
+ every { activity.isChangingConfigurations } returns false
515
+ callbacks!!.onActivityStarted(activity)
516
+ callbacks!!.onActivityStopped(activity)
517
+
518
+ verify(exactly = 0) { pm.setComponentEnabledSettings(any()) }
519
+ }
520
+
521
+ @Test
522
+ fun last_activity_stop_apply_failure_stores_last_error_without_throwing() {
523
+ val pm = mockk<PackageManager>(relaxed = true)
524
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(context.packageName)
525
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo()
526
+ every { pm.setComponentEnabledSettings(any<List<PackageManager.ComponentEnabledSetting>>()) } throws
527
+ RuntimeException("pm failed")
528
+ every { pm.getComponentEnabledSetting(any()) } returns PackageManager.COMPONENT_ENABLED_STATE_DISABLED
529
+
530
+ val prefs = context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE)
531
+ prefs.edit().putString(IconSwitcher.KEY_PENDING, "halloween").apply()
532
+
533
+ var callbacks: Application.ActivityLifecycleCallbacks? = null
534
+ val switcher = switcherWithActivityCallbacks(pm) { callbacks = it }
535
+ switcher.onProcessStart()
536
+
537
+ val activity = mockk<Activity>(relaxed = true)
538
+ every { activity.isChangingConfigurations } returns false
539
+ callbacks!!.onActivityStarted(activity)
540
+ callbacks!!.onActivityStopped(activity)
541
+
542
+ assertEquals("halloween", prefs.getString(IconSwitcher.KEY_PENDING, null))
543
+ assertEquals("pm failed", prefs.getString(IconSwitcher.KEY_LAST_ERROR, null))
544
+ }
545
+
546
+ private fun switcherWithActivityCallbacks(
547
+ pm: PackageManager,
548
+ prefs: android.content.SharedPreferences =
549
+ context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE),
550
+ onRegister: (Application.ActivityLifecycleCallbacks) -> Unit = { },
551
+ ): IconSwitcher {
552
+ return IconSwitcher(
553
+ context = context,
554
+ packageManager = pm,
555
+ packageName = context.packageName,
556
+ prefs = prefs,
557
+ lifecycleRegistrar = { },
558
+ activityLifecycleRegistrar = onRegister,
559
+ isInForeground = { false },
560
+ )
561
+ }
562
+
334
563
  private fun switcherWithForeground(
335
564
  pm: PackageManager,
336
565
  inForeground: Boolean,
@@ -343,6 +572,7 @@ class IconSwitcherTest {
343
572
  packageName = context.packageName,
344
573
  prefs = prefs,
345
574
  lifecycleRegistrar = { },
575
+ activityLifecycleRegistrar = { },
346
576
  isInForeground = { inForeground },
347
577
  )
348
578
  }
@@ -48,6 +48,16 @@ function _isFailedBlocked(failed, choice, retryAfterMs) {
48
48
  return Date.now() - failed.at < retryAfterMs;
49
49
  }
50
50
 
51
+ // Shared by every syncIcon instance: callers restart syncIcon when their inputs change, and an
52
+ // older instance's run can still be waiting on the OS (iOS shows an alert per set call). Running
53
+ // one at a time lets the next run see the icon the previous one applied.
54
+ let _queue = Promise.resolve();
55
+ function _enqueue(task) {
56
+ const next = _queue.then(task, task);
57
+ _queue = next.catch(() => {});
58
+ return next;
59
+ }
60
+
51
61
  /**
52
62
  * Fetches the remote icon choice, validates it, and applies it via {@link IconSwitcher}.
53
63
  * iOS: runs while the app is `active`. Android: calls `set` immediately (native may defer).
@@ -64,13 +74,17 @@ export async function syncIcon(options) {
64
74
  }
65
75
  };
66
76
  let inFlight = false;
67
- const run = async () => {
68
- if (inFlight) {
69
- return;
70
- }
71
- inFlight = true;
77
+ let stopped = false;
78
+ const _apply = async () => {
72
79
  try {
80
+ // Unsubscribed while queued: the caller has moved on to a newer syncIcon.
81
+ if (stopped) {
82
+ return;
83
+ }
73
84
  const choice = await options.fetchChoice();
85
+ if (stopped) {
86
+ return;
87
+ }
74
88
  if (choice == null || choice === '') {
75
89
  return;
76
90
  }
@@ -103,6 +117,15 @@ export async function syncIcon(options) {
103
117
  }
104
118
  } catch (error) {
105
119
  reportError(error);
120
+ }
121
+ };
122
+ const run = async () => {
123
+ if (inFlight || stopped) {
124
+ return;
125
+ }
126
+ inFlight = true;
127
+ try {
128
+ await _enqueue(_apply);
106
129
  } finally {
107
130
  inFlight = false;
108
131
  }
@@ -119,9 +142,14 @@ export async function syncIcon(options) {
119
142
  }
120
143
  });
121
144
  runWhenActive();
122
- return () => subscription.remove();
145
+ return () => {
146
+ stopped = true;
147
+ subscription.remove();
148
+ };
123
149
  }
124
150
  await run();
125
- return () => {};
151
+ return () => {
152
+ stopped = true;
153
+ };
126
154
  }
127
155
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"names":["AppState","Platform","IconSwitcher","STORAGE_KEY_APPLIED","STORAGE_KEY_FAILED","DEFAULT_RETRY_AFTER_MS","_createMemoryStorage","data","Map","getItem","key","get","setItem","value","set","_encodeFailed","name","JSON","stringify","at","Date","now","_decodeFailed","raw","length","parsed","parse","_isFailedBlocked","failed","choice","retryAfterMs","syncIcon","options","storage","reportError","error","onError","inFlight","run","fetchChoice","list","includes","isSupported","failedRaw","current","getCurrent","result","status","runWhenActive","currentState","OS","subscription","addEventListener","state","remove"],"sourceRoot":"../../../src","sources":["remote/index.ts"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"names":["AppState","Platform","IconSwitcher","STORAGE_KEY_APPLIED","STORAGE_KEY_FAILED","DEFAULT_RETRY_AFTER_MS","_createMemoryStorage","data","Map","getItem","key","get","setItem","value","set","_encodeFailed","name","JSON","stringify","at","Date","now","_decodeFailed","raw","length","parsed","parse","_isFailedBlocked","failed","choice","retryAfterMs","_queue","Promise","resolve","_enqueue","task","next","then","catch","syncIcon","options","storage","reportError","error","onError","inFlight","stopped","_apply","fetchChoice","list","includes","isSupported","failedRaw","current","getCurrent","result","status","run","runWhenActive","currentState","OS","subscription","addEventListener","state","remove"],"sourceRoot":"../../../src","sources":["remote/index.ts"],"mappings":"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","ignoreList":[]}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/remote/index.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC7C,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpD;AAED,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IACtD,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B,4EAA4E;IAC5E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC;AA6CD;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CA+E5E"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/remote/index.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC7C,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpD;AAED,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IACtD,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B,4EAA4E;IAC5E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC;AAwDD;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAkG5E"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-icon-switcher",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Switch the app icon at runtime on React Native and Expo from one config file, with the native wiring generated for you",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -64,6 +64,17 @@ function _isFailedBlocked(
64
64
  return Date.now() - failed.at < retryAfterMs;
65
65
  }
66
66
 
67
+ // Shared by every syncIcon instance: callers restart syncIcon when their inputs change, and an
68
+ // older instance's run can still be waiting on the OS (iOS shows an alert per set call). Running
69
+ // one at a time lets the next run see the icon the previous one applied.
70
+ let _queue: Promise<void> = Promise.resolve();
71
+
72
+ function _enqueue(task: () => Promise<void>): Promise<void> {
73
+ const next = _queue.then(task, task);
74
+ _queue = next.catch(() => {});
75
+ return next;
76
+ }
77
+
67
78
  /**
68
79
  * Fetches the remote icon choice, validates it, and applies it via {@link IconSwitcher}.
69
80
  * iOS: runs while the app is `active`. Android: calls `set` immediately (native may defer).
@@ -81,14 +92,18 @@ export async function syncIcon(options: SyncIconOptions): Promise<() => void> {
81
92
  };
82
93
 
83
94
  let inFlight = false;
95
+ let stopped = false;
84
96
 
85
- const run = async (): Promise<void> => {
86
- if (inFlight) {
87
- return;
88
- }
89
- inFlight = true;
97
+ const _apply = async (): Promise<void> => {
90
98
  try {
99
+ // Unsubscribed while queued: the caller has moved on to a newer syncIcon.
100
+ if (stopped) {
101
+ return;
102
+ }
91
103
  const choice = await options.fetchChoice();
104
+ if (stopped) {
105
+ return;
106
+ }
92
107
  if (choice == null || choice === '') {
93
108
  return;
94
109
  }
@@ -124,6 +139,16 @@ export async function syncIcon(options: SyncIconOptions): Promise<() => void> {
124
139
  }
125
140
  } catch (error) {
126
141
  reportError(error);
142
+ }
143
+ };
144
+
145
+ const run = async (): Promise<void> => {
146
+ if (inFlight || stopped) {
147
+ return;
148
+ }
149
+ inFlight = true;
150
+ try {
151
+ await _enqueue(_apply);
127
152
  } finally {
128
153
  inFlight = false;
129
154
  }
@@ -142,9 +167,14 @@ export async function syncIcon(options: SyncIconOptions): Promise<() => void> {
142
167
  }
143
168
  });
144
169
  runWhenActive();
145
- return () => subscription.remove();
170
+ return () => {
171
+ stopped = true;
172
+ subscription.remove();
173
+ };
146
174
  }
147
175
 
148
176
  await run();
149
- return () => {};
177
+ return () => {
178
+ stopped = true;
179
+ };
150
180
  }