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 +5 -1
- package/android/build.gradle +1 -0
- package/android/src/main/AndroidManifest.xml +15 -1
- package/android/src/main/java/com/iconswitcher/IconSwitcher.kt +85 -10
- package/android/src/main/java/com/iconswitcher/IconSwitcherInitializer.kt +15 -0
- package/android/src/main/java/com/iconswitcher/IconSwitcherModule.kt +1 -1
- package/android/src/test/java/com/iconswitcher/IconSwitcherTest.kt +230 -0
- package/package.json +1 -1
- package/plugin/build/core/assets.d.ts +20 -0
- package/plugin/build/core/assets.js +216 -22
- package/plugin/build/index.js +1 -0
- package/plugin/build/sync.js +13 -0
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
|
|
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
|
|
package/android/build.gradle
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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(
|
|
349
|
+
.png()
|
|
282
350
|
.toBuffer();
|
|
283
351
|
}
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
|
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
|
|
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:
|
|
301
|
-
height:
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
return
|
|
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;
|
package/plugin/build/index.js
CHANGED
|
@@ -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
|
},
|
package/plugin/build/sync.js
CHANGED
|
@@ -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 });
|