react-native-icon-switcher 0.1.4 → 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
@@ -107,6 +107,10 @@ Icons with no art for a platform are omitted from that platform's generated outp
107
107
 
108
108
  **Android reserved names:** names may not be `round`, `foreground`, `background`, or `monochrome`, nor end in `_round`, `_foreground`, `_background`, or `_monochrome`.
109
109
 
110
+ **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
+
112
+ **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
+
110
114
  ## Usage
111
115
 
112
116
  ```ts
@@ -238,7 +242,7 @@ it('applies a pending switch', async () => {
238
242
 
239
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).
240
244
 
241
- 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`.
242
246
 
243
247
  ## Requirements
244
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.4",
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",
@@ -1,4 +1,17 @@
1
1
  import type { HexColor, ResolvedConfig } from '../types';
2
+ /** Drop shadow tuned to match Image Asset exports at xxxhdpi. */
3
+ export declare const LEGACY_SHADOW_OFFSET_Y_DP = 0.75;
4
+ export declare const LEGACY_SHADOW_BLUR_DP = 1;
5
+ export declare const LEGACY_SHADOW_OPACITY = 0.25;
6
+ /**
7
+ * Largest per-channel difference at which two PNGs still count as the same
8
+ * image. libvips resampling rounds slightly differently on arm64 and x86, so
9
+ * icons generated on a Mac and re-generated in Linux CI can differ by a level
10
+ * or two without any visible change.
11
+ */
12
+ export declare const PNG_PIXEL_TOLERANCE = 3;
13
+ /** True when both buffers are PNGs of the same size whose pixels differ by at most `PNG_PIXEL_TOLERANCE`. */
14
+ export declare function pngsMatchWithinTolerance(a: Buffer, b: Buffer): Promise<boolean>;
2
15
  export type IosVariant = 'light' | 'dark' | 'tinted';
3
16
  export type AssetJob = {
4
17
  kind: 'ios-png';
@@ -72,6 +85,13 @@ export declare function writeAssets(plan: AssetPlan, opts: {
72
85
  iosAssetCatalog: string;
73
86
  androidResDir: string;
74
87
  }): Promise<WrittenFile[]>;
88
+ /**
89
+ * Remove unreferenced files from managed `.appiconset` folders in `plan`.
90
+ * Returns absolute paths of stale files (removed or that would be removed in check mode).
91
+ */
92
+ export declare function pruneStaleIosAppIconSets(iosAssetCatalog: string, plan: AssetPlan, opts: {
93
+ check: boolean;
94
+ }): Promise<string[]>;
75
95
  /** Test helper: corner pixel RGB after iOS flatten (alpha removed). */
76
96
  export declare function _readCornerRgb(pngPath: string): Promise<{
77
97
  r: number;
@@ -3,8 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.PNG_PIXEL_TOLERANCE = exports.LEGACY_SHADOW_OPACITY = exports.LEGACY_SHADOW_BLUR_DP = exports.LEGACY_SHADOW_OFFSET_Y_DP = void 0;
7
+ exports.pngsMatchWithinTolerance = pngsMatchWithinTolerance;
6
8
  exports.planAssets = planAssets;
7
9
  exports.writeAssets = writeAssets;
10
+ exports.pruneStaleIosAppIconSets = pruneStaleIosAppIconSets;
8
11
  exports._readCornerRgb = _readCornerRgb;
9
12
  const node_crypto_1 = __importDefault(require("node:crypto"));
10
13
  const promises_1 = __importDefault(require("node:fs/promises"));
@@ -25,6 +28,47 @@ const ADAPTIVE_DENSITIES = [
25
28
  { folder: 'mipmap-xxxhdpi', size: 432 },
26
29
  ];
27
30
  const PNG_OPTIONS = { compressionLevel: 9, adaptiveFiltering: false };
31
+ /** Legacy launcher canvas (API 24–25), matching Android Studio Image Asset at 48 dp. */
32
+ const LEGACY_CANVAS_DP = 48;
33
+ const LEGACY_SQUARE_INSET_DP = 5;
34
+ const LEGACY_SQUARE_BODY_DP = 38;
35
+ const LEGACY_SQUARE_RADIUS_DP = 3;
36
+ const LEGACY_ROUND_INSET_DP = 2;
37
+ const LEGACY_ROUND_DIAMETER_DP = 44;
38
+ const ADAPTIVE_LAYER_DP = 108;
39
+ const ADAPTIVE_VIEWPORT_DP = 72;
40
+ /** Drop shadow tuned to match Image Asset exports at xxxhdpi. */
41
+ exports.LEGACY_SHADOW_OFFSET_Y_DP = 0.75;
42
+ exports.LEGACY_SHADOW_BLUR_DP = 1;
43
+ exports.LEGACY_SHADOW_OPACITY = 0.25;
44
+ /**
45
+ * Largest per-channel difference at which two PNGs still count as the same
46
+ * image. libvips resampling rounds slightly differently on arm64 and x86, so
47
+ * icons generated on a Mac and re-generated in Linux CI can differ by a level
48
+ * or two without any visible change.
49
+ */
50
+ exports.PNG_PIXEL_TOLERANCE = 3;
51
+ const PNG_SIGNATURE = Buffer.from([
52
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
53
+ ]);
54
+ /** True when both buffers are PNGs of the same size whose pixels differ by at most `PNG_PIXEL_TOLERANCE`. */
55
+ async function pngsMatchWithinTolerance(a, b) {
56
+ if (!a.subarray(0, 8).equals(PNG_SIGNATURE) ||
57
+ !b.subarray(0, 8).equals(PNG_SIGNATURE)) {
58
+ return false;
59
+ }
60
+ const [left, right] = await Promise.all([a, b].map((buf) => (0, sharp_1.default)(buf).ensureAlpha().raw().toBuffer({ resolveWithObject: true })));
61
+ if (left.info.width !== right.info.width ||
62
+ left.info.height !== right.info.height) {
63
+ return false;
64
+ }
65
+ for (let i = 0; i < left.data.length; i += 1) {
66
+ if (Math.abs(left.data[i] - right.data[i]) > exports.PNG_PIXEL_TOLERANCE) {
67
+ return false;
68
+ }
69
+ }
70
+ return true;
71
+ }
28
72
  function _iosSetFolder(icon) {
29
73
  return icon.isDefault
30
74
  ? 'AppIcon.appiconset'
@@ -274,31 +318,123 @@ async function _flattenPng(sourceAbs, flattenOn, width, height) {
274
318
  .png(PNG_OPTIONS)
275
319
  .toBuffer();
276
320
  }
277
- async function _applyRoundMask(image, size) {
278
- const mask = Buffer.from(`<svg width="${size}" height="${size}"><circle cx="${size / 2}" cy="${size / 2}" r="${size / 2}" fill="white"/></svg>`);
279
- return (0, sharp_1.default)(image)
321
+ function _legacyDpToPx(dp, canvasPx) {
322
+ return Math.round((dp * canvasPx) / LEGACY_CANVAS_DP);
323
+ }
324
+ function _legacyShapeLayout(canvasPx, round) {
325
+ if (round) {
326
+ const inset = _legacyDpToPx(LEGACY_ROUND_INSET_DP, canvasPx);
327
+ const bodyPx = _legacyDpToPx(LEGACY_ROUND_DIAMETER_DP, canvasPx);
328
+ return { inset, bodyPx, radiusPx: bodyPx / 2 };
329
+ }
330
+ return {
331
+ inset: _legacyDpToPx(LEGACY_SQUARE_INSET_DP, canvasPx),
332
+ bodyPx: _legacyDpToPx(LEGACY_SQUARE_BODY_DP, canvasPx),
333
+ radiusPx: _legacyDpToPx(LEGACY_SQUARE_RADIUS_DP, canvasPx),
334
+ };
335
+ }
336
+ function _legacyShapeMaskSvg(bodyPx, round, radiusPx) {
337
+ if (round) {
338
+ const r = bodyPx / 2;
339
+ return Buffer.from(`<svg width="${bodyPx}" height="${bodyPx}"><circle cx="${r}" cy="${r}" r="${r}" fill="white"/></svg>`);
340
+ }
341
+ return Buffer.from(`<svg width="${bodyPx}" height="${bodyPx}"><rect width="${bodyPx}" height="${bodyPx}" rx="${radiusPx}" ry="${radiusPx}" fill="white"/></svg>`);
342
+ }
343
+ async function _legacyMaskedContent(content, bodyPx, round, radiusPx) {
344
+ const mask = _legacyShapeMaskSvg(bodyPx, round, radiusPx);
345
+ return (0, sharp_1.default)(content)
346
+ .resize(bodyPx, bodyPx, { fit: 'cover' })
347
+ .ensureAlpha()
280
348
  .composite([{ input: mask, blend: 'dest-in' }])
281
- .png(PNG_OPTIONS)
349
+ .png()
282
350
  .toBuffer();
283
351
  }
284
- async function _legacyPng(sourceAbs, size, round) {
285
- const resized = await (0, sharp_1.default)(sourceAbs)
286
- .resize(size, size, { fit: 'cover' })
287
- .png()
352
+ function _boxBlurLine(src, size, radius, horizontal) {
353
+ const out = new Uint8Array(src.length);
354
+ const window = radius * 2 + 1;
355
+ for (let line = 0; line < size; line += 1) {
356
+ const at = (i) => (horizontal ? line * size + i : i * size + line);
357
+ let sum = 0;
358
+ for (let i = -radius; i <= radius; i += 1) {
359
+ if (i >= 0 && i < size) {
360
+ sum += src[at(i)];
361
+ }
362
+ }
363
+ for (let i = 0; i < size; i += 1) {
364
+ out[at(i)] = Math.floor(sum / window);
365
+ const leaving = i - radius;
366
+ const entering = i + radius + 1;
367
+ if (leaving >= 0) {
368
+ sum -= src[at(leaving)];
369
+ }
370
+ if (entering < size) {
371
+ sum += src[at(entering)];
372
+ }
373
+ }
374
+ }
375
+ return out;
376
+ }
377
+ /**
378
+ * Black drop shadow of the shape on a transparent canvas. The blur is done in
379
+ * integer arithmetic here rather than with an SVG filter: librsvg's float blur
380
+ * produced different bytes on macOS and Linux, which broke `sync --check` in CI.
381
+ */
382
+ async function _legacyShadowLayer(canvasPx, shapeMarkup, offsetY, blurPx) {
383
+ const shapeSvg = Buffer.from(`<svg width="${canvasPx}" height="${canvasPx}" xmlns="http://www.w3.org/2000/svg">${shapeMarkup}</svg>`);
384
+ const shape = await (0, sharp_1.default)(shapeSvg)
385
+ .ensureAlpha()
386
+ .extractChannel(3)
387
+ .raw()
288
388
  .toBuffer();
289
- if (!round) {
290
- return (0, sharp_1.default)(resized).png(PNG_OPTIONS).toBuffer();
389
+ // Three box-blur passes approximate a Gaussian.
390
+ const radius = Math.max(1, Math.round(blurPx / 2));
391
+ let alpha = Uint8Array.from(shape);
392
+ for (let pass = 0; pass < 3; pass += 1) {
393
+ alpha = _boxBlurLine(alpha, canvasPx, radius, true);
394
+ alpha = _boxBlurLine(alpha, canvasPx, radius, false);
395
+ }
396
+ const opacity256 = Math.round(exports.LEGACY_SHADOW_OPACITY * 256);
397
+ const rgba = Buffer.alloc(canvasPx * canvasPx * 4);
398
+ for (let y = 0; y < canvasPx; y += 1) {
399
+ const sourceY = y - offsetY;
400
+ if (sourceY < 0 || sourceY >= canvasPx) {
401
+ continue;
402
+ }
403
+ for (let x = 0; x < canvasPx; x += 1) {
404
+ const a = alpha[sourceY * canvasPx + x];
405
+ rgba[(y * canvasPx + x) * 4 + 3] = Math.floor((a * opacity256) / 256);
406
+ }
291
407
  }
292
- return _applyRoundMask(resized, size);
408
+ return (0, sharp_1.default)(rgba, {
409
+ raw: { width: canvasPx, height: canvasPx, channels: 4 },
410
+ })
411
+ .png()
412
+ .toBuffer();
293
413
  }
294
- async function _legacyCompositePng(foregroundAbs, size, round, background) {
414
+ async function _legacyStudioPng(content, canvasPx, round) {
415
+ const { inset, bodyPx, radiusPx } = _legacyShapeLayout(canvasPx, round);
416
+ const masked = await _legacyMaskedContent(content, bodyPx, round, radiusPx);
417
+ const shadowOffsetY = _legacyDpToPx(exports.LEGACY_SHADOW_OFFSET_Y_DP, canvasPx);
418
+ const blurPx = Math.max(1, _legacyDpToPx(exports.LEGACY_SHADOW_BLUR_DP, canvasPx));
419
+ const cx = inset + bodyPx / 2;
420
+ const cy = inset + bodyPx / 2;
421
+ const shapeMarkup = round
422
+ ? `<circle cx="${cx}" cy="${cy}" r="${bodyPx / 2}" fill="#000"/>`
423
+ : `<rect x="${inset}" y="${inset}" width="${bodyPx}" height="${bodyPx}" rx="${radiusPx}" ry="${radiusPx}" fill="#000"/>`;
424
+ const shadowLayer = await _legacyShadowLayer(canvasPx, shapeMarkup, shadowOffsetY, blurPx);
425
+ return (0, sharp_1.default)(shadowLayer)
426
+ .composite([{ input: masked, left: inset, top: inset, blend: 'over' }])
427
+ .png(PNG_OPTIONS)
428
+ .toBuffer();
429
+ }
430
+ async function _adaptiveLayerComposite(foregroundAbs, layerPx, background) {
295
431
  let base;
296
432
  if ('color' in background) {
297
433
  const { r, g, b } = _hexToRgb(background.color);
298
434
  base = await (0, sharp_1.default)({
299
435
  create: {
300
- width: size,
301
- height: size,
436
+ width: layerPx,
437
+ height: layerPx,
302
438
  channels: 4,
303
439
  background: { r, g, b, alpha: 255 },
304
440
  },
@@ -308,25 +444,41 @@ async function _legacyCompositePng(foregroundAbs, size, round, background) {
308
444
  }
309
445
  else {
310
446
  base = await (0, sharp_1.default)(background.imageAbs)
311
- .resize(size, size, { fit: 'cover' })
447
+ .resize(layerPx, layerPx, { fit: 'cover' })
312
448
  .png()
313
449
  .toBuffer();
314
450
  }
315
451
  const foreground = await (0, sharp_1.default)(foregroundAbs)
316
- .resize(size, size, {
452
+ .resize(layerPx, layerPx, {
317
453
  fit: 'contain',
318
454
  background: { r: 0, g: 0, b: 0, alpha: 0 },
319
455
  })
320
456
  .png()
321
457
  .toBuffer();
322
- const flat = await (0, sharp_1.default)(base)
458
+ const cropPx = Math.round((layerPx * ADAPTIVE_VIEWPORT_DP) / ADAPTIVE_LAYER_DP);
459
+ const cropInset = Math.round((layerPx - cropPx) / 2);
460
+ const composed = await (0, sharp_1.default)(base)
323
461
  .composite([{ input: foreground, blend: 'over' }])
324
- .png(PNG_OPTIONS)
462
+ .png()
463
+ .toBuffer();
464
+ return (0, sharp_1.default)(composed)
465
+ .extract({
466
+ left: cropInset,
467
+ top: cropInset,
468
+ width: cropPx,
469
+ height: cropPx,
470
+ })
471
+ .png()
325
472
  .toBuffer();
326
- if (!round) {
327
- return flat;
328
- }
329
- return _applyRoundMask(flat, size);
473
+ }
474
+ async function _legacyPng(sourceAbs, size, round) {
475
+ const content = await (0, sharp_1.default)(sourceAbs).ensureAlpha().png().toBuffer();
476
+ return _legacyStudioPng(content, size, round);
477
+ }
478
+ async function _legacyCompositePng(foregroundAbs, size, round, background) {
479
+ const layerPx = Math.round((size * ADAPTIVE_LAYER_DP) / LEGACY_CANVAS_DP);
480
+ const viewport = await _adaptiveLayerComposite(foregroundAbs, layerPx, background);
481
+ return _legacyStudioPng(viewport, size, round);
330
482
  }
331
483
  async function _resizePng(sourceAbs, size) {
332
484
  return (0, sharp_1.default)(sourceAbs)
@@ -452,6 +604,48 @@ async function writeAssets(plan, opts) {
452
604
  }
453
605
  return written;
454
606
  }
607
+ function _iosFilenamesForContentsJob(job) {
608
+ const keep = new Set(['Contents.json']);
609
+ for (const variant of job.variants) {
610
+ keep.add(variant === 'light' ? 'AppIcon-1024.png' : `AppIcon-1024-${variant}.png`);
611
+ }
612
+ return keep;
613
+ }
614
+ /**
615
+ * Remove unreferenced files from managed `.appiconset` folders in `plan`.
616
+ * Returns absolute paths of stale files (removed or that would be removed in check mode).
617
+ */
618
+ async function pruneStaleIosAppIconSets(iosAssetCatalog, plan, opts) {
619
+ const staleAbs = [];
620
+ for (const asset of plan.assets) {
621
+ if (asset.job.kind !== 'ios-contents') {
622
+ continue;
623
+ }
624
+ const setRel = node_path_1.default.dirname(asset.relativePath);
625
+ const setDir = node_path_1.default.join(iosAssetCatalog, setRel);
626
+ const keep = _iosFilenamesForContentsJob(asset.job);
627
+ let entries;
628
+ try {
629
+ entries = await promises_1.default.readdir(setDir, { withFileTypes: true });
630
+ }
631
+ catch {
632
+ continue;
633
+ }
634
+ for (const entry of entries) {
635
+ const name = entry.name;
636
+ // Only plain files are ours to remove; leave folders and links alone.
637
+ if (!entry.isFile() || keep.has(name)) {
638
+ continue;
639
+ }
640
+ const abs = node_path_1.default.join(setDir, name);
641
+ staleAbs.push(abs);
642
+ if (!opts.check) {
643
+ await promises_1.default.unlink(abs);
644
+ }
645
+ }
646
+ }
647
+ return staleAbs;
648
+ }
455
649
  /** Test helper: corner pixel RGB after iOS flatten (alpha removed). */
456
650
  async function _readCornerRgb(pngPath) {
457
651
  var _a, _b, _c;
@@ -125,6 +125,7 @@ const withIconSwitcher = (config, props) => {
125
125
  iosAssetCatalog,
126
126
  androidResDir: path_1.default.join(projectRoot, '.noop-android'),
127
127
  });
128
+ await (0, assets_1.pruneStaleIosAppIconSets)(iosAssetCatalog, plan, { check: false });
128
129
  await _writeTypesIfChanged(projectRoot, resolved);
129
130
  return config;
130
131
  },
@@ -36,6 +36,12 @@ async function _applyFileChange(_projectRoot, abs, next, check) {
36
36
  if (existing !== null && existing.equals(nextBuf)) {
37
37
  return 'unchanged';
38
38
  }
39
+ // Keep a generated image that only differs by resampling rounding (for
40
+ // example made on another CPU), so --check passes and nothing is rewritten.
41
+ if (existing !== null &&
42
+ (await (0, assets_1.pngsMatchWithinTolerance)(existing, nextBuf))) {
43
+ return 'unchanged';
44
+ }
39
45
  if (check) {
40
46
  return 'updated';
41
47
  }
@@ -76,6 +82,13 @@ async function _syncAssets(resolved, projectRoot, iosAssetCatalog, androidResDir
76
82
  unchanged += 1;
77
83
  }
78
84
  }
85
+ const staleIos = await (0, assets_1.pruneStaleIosAppIconSets)(iosAssetCatalog, plan, {
86
+ check,
87
+ });
88
+ for (const abs of staleIos) {
89
+ driftedPaths.push(_rel(projectRoot, abs));
90
+ updated += 1;
91
+ }
79
92
  }
80
93
  finally {
81
94
  await promises_1.default.rm(tmpRoot, { recursive: true, force: true });