react-native-icon-switcher 0.1.6 → 0.1.7

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
@@ -242,7 +242,7 @@ it('applies a pending switch', async () => {
242
242
 
243
243
  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
244
 
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`.
245
+ 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
246
 
247
247
  ## Requirements
248
248
 
@@ -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
  }
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.7",
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",