react-native-icon-switcher 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/IconSwitcher.podspec +20 -0
  2. package/LICENSE +20 -0
  3. package/README.md +252 -2
  4. package/android/build.gradle +51 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/iconswitcher/IconSwitcher.kt +380 -0
  7. package/android/src/main/java/com/iconswitcher/IconSwitcherExceptions.kt +24 -0
  8. package/android/src/main/java/com/iconswitcher/IconSwitcherModule.kt +67 -0
  9. package/android/src/main/java/com/iconswitcher/IconSwitcherPackage.kt +30 -0
  10. package/android/src/test/AndroidManifest.xml +39 -0
  11. package/android/src/test/java/com/iconswitcher/IconSwitcherTest.kt +392 -0
  12. package/app.plugin.js +1 -0
  13. package/ios/ISIconSwitcher.h +49 -0
  14. package/ios/ISIconSwitcher.m +205 -0
  15. package/ios/IconSwitcher.h +5 -0
  16. package/ios/IconSwitcher.mm +100 -0
  17. package/jest/index.d.ts +31 -0
  18. package/jest/index.js +98 -0
  19. package/lib/module/NativeIconSwitcher.js +5 -0
  20. package/lib/module/NativeIconSwitcher.js.map +1 -0
  21. package/lib/module/config.js +6 -0
  22. package/lib/module/config.js.map +1 -0
  23. package/lib/module/errors.js +28 -0
  24. package/lib/module/errors.js.map +1 -0
  25. package/lib/module/index.js +69 -0
  26. package/lib/module/index.js.map +1 -0
  27. package/lib/module/package.json +1 -0
  28. package/lib/module/register.js +2 -0
  29. package/lib/module/register.js.map +1 -0
  30. package/lib/module/remote/index.js +119 -0
  31. package/lib/module/remote/index.js.map +1 -0
  32. package/lib/module/types.js +2 -0
  33. package/lib/module/types.js.map +1 -0
  34. package/lib/typescript/package.json +1 -0
  35. package/lib/typescript/src/NativeIconSwitcher.d.ts +18 -0
  36. package/lib/typescript/src/NativeIconSwitcher.d.ts.map +1 -0
  37. package/lib/typescript/src/config.d.ts +4 -0
  38. package/lib/typescript/src/config.d.ts.map +1 -0
  39. package/lib/typescript/src/errors.d.ts +7 -0
  40. package/lib/typescript/src/errors.d.ts.map +1 -0
  41. package/lib/typescript/src/index.d.ts +20 -0
  42. package/lib/typescript/src/index.d.ts.map +1 -0
  43. package/lib/typescript/src/register.d.ts +7 -0
  44. package/lib/typescript/src/register.d.ts.map +1 -0
  45. package/lib/typescript/src/remote/index.d.ts +17 -0
  46. package/lib/typescript/src/remote/index.d.ts.map +1 -0
  47. package/lib/typescript/src/types.d.ts +59 -0
  48. package/lib/typescript/src/types.d.ts.map +1 -0
  49. package/package.json +253 -4
  50. package/plugin/build/cli.d.ts +3 -0
  51. package/plugin/build/cli.js +89 -0
  52. package/plugin/build/core/assets.d.ts +80 -0
  53. package/plugin/build/core/assets.js +463 -0
  54. package/plugin/build/core/errors.d.ts +4 -0
  55. package/plugin/build/core/errors.js +11 -0
  56. package/plugin/build/core/validate.d.ts +6 -0
  57. package/plugin/build/core/validate.js +229 -0
  58. package/plugin/build/index.d.ts +6 -0
  59. package/plugin/build/index.js +134 -0
  60. package/plugin/build/load-config.d.ts +3 -0
  61. package/plugin/build/load-config.js +50 -0
  62. package/plugin/build/paths.d.ts +15 -0
  63. package/plugin/build/paths.js +153 -0
  64. package/plugin/build/sync.d.ts +12 -0
  65. package/plugin/build/sync.js +162 -0
  66. package/plugin/build/transforms/android.d.ts +5 -0
  67. package/plugin/build/transforms/android.js +212 -0
  68. package/plugin/build/transforms/errors.d.ts +4 -0
  69. package/plugin/build/transforms/errors.js +11 -0
  70. package/plugin/build/transforms/index.d.ts +4 -0
  71. package/plugin/build/transforms/index.js +12 -0
  72. package/plugin/build/transforms/ios.d.ts +4 -0
  73. package/plugin/build/transforms/ios.js +111 -0
  74. package/plugin/build/transforms/sampleConfigs.d.ts +3 -0
  75. package/plugin/build/transforms/sampleConfigs.js +33 -0
  76. package/plugin/build/transforms/types.d.ts +2 -0
  77. package/plugin/build/transforms/types.js +14 -0
  78. package/plugin/build/types.d.ts +61 -0
  79. package/plugin/build/types.js +5 -0
  80. package/src/NativeIconSwitcher.ts +17 -0
  81. package/src/config.ts +15 -0
  82. package/src/errors.ts +38 -0
  83. package/src/index.tsx +78 -0
  84. package/src/register.ts +4 -0
  85. package/src/remote/index.ts +142 -0
  86. package/src/types.ts +59 -0
@@ -0,0 +1,392 @@
1
+ package com.iconswitcher
2
+
3
+ import android.content.ComponentName
4
+ import android.content.Context
5
+ import android.content.pm.ActivityInfo
6
+ import android.content.pm.ApplicationInfo
7
+ import android.content.pm.PackageInfo
8
+ import android.content.pm.PackageManager
9
+ import android.os.Bundle
10
+ import androidx.lifecycle.DefaultLifecycleObserver
11
+ import androidx.lifecycle.LifecycleOwner
12
+ import androidx.test.core.app.ApplicationProvider
13
+ import io.mockk.every
14
+ import io.mockk.mockk
15
+ import io.mockk.slot
16
+ import io.mockk.verify
17
+ import java.util.concurrent.CountDownLatch
18
+ import java.util.concurrent.TimeUnit
19
+ import org.junit.Assert.assertEquals
20
+ import org.junit.Assert.assertFalse
21
+ import org.junit.Assert.assertThrows
22
+ import org.junit.Assert.assertTrue
23
+ import org.junit.Before
24
+ import org.junit.Test
25
+ import org.junit.runner.RunWith
26
+ import org.robolectric.RobolectricTestRunner
27
+ import org.robolectric.annotation.Config
28
+ import org.robolectric.shadows.ShadowLooper
29
+
30
+ @RunWith(RobolectricTestRunner::class)
31
+ @Config(sdk = [33])
32
+ class IconSwitcherTest {
33
+ private lateinit var context: Context
34
+
35
+ @Before
36
+ fun setUp() {
37
+ context = ApplicationProvider.getApplicationContext()
38
+ context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE)
39
+ .edit()
40
+ .clear()
41
+ .apply()
42
+ }
43
+
44
+ @Test
45
+ fun discovery_ignores_unmarked_aliases() {
46
+ val switcher = IconSwitcher(context)
47
+ assertEquals(listOf("default", "halloween"), switcher.iconNames)
48
+ assertFalse(switcher.iconNames.contains("UnmarkedAlias"))
49
+ }
50
+
51
+ @Test
52
+ fun icon_names_follow_metadata_order_when_package_info_order_differs() {
53
+ val pm = mockk<PackageManager>(relaxed = true)
54
+ val packageName = context.packageName
55
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(
56
+ packageName,
57
+ iconOrder = listOf("halloween", "zebra", "default"),
58
+ )
59
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo(
60
+ icons = "default,zebra,halloween",
61
+ )
62
+
63
+ val switcher = IconSwitcher(context, pm)
64
+ assertEquals(listOf("default", "zebra", "halloween"), switcher.iconNames)
65
+ }
66
+
67
+ @Test
68
+ fun set_in_background_mode_returns_pending_and_does_not_touch_package_manager() {
69
+ val pm = mockk<PackageManager>(relaxed = true)
70
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(context.packageName)
71
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo()
72
+
73
+ val switcher = IconSwitcher(context, pm)
74
+ val result = switcher.set("halloween")
75
+
76
+ assertEquals(IconSwitcher.STATUS_PENDING, result.status)
77
+ verify(exactly = 0) { pm.setComponentEnabledSetting(any(), any(), any()) }
78
+ verify(exactly = 0) { pm.setComponentEnabledSettings(any()) }
79
+ }
80
+
81
+ @Test
82
+ fun apply_toggles_marked_aliases_with_dont_kill_app() {
83
+ val pm = mockk<PackageManager>(relaxed = true)
84
+ val packageName = context.packageName
85
+ val aliases = robolectricPackageInfo(packageName)
86
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns aliases
87
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo(
88
+ applyOn = IconSwitcher.APPLY_ON_IMMEDIATE,
89
+ )
90
+ val settingsSlot = slot<List<PackageManager.ComponentEnabledSetting>>()
91
+ every { pm.setComponentEnabledSettings(capture(settingsSlot)) } returns Unit
92
+ every { pm.getComponentEnabledSetting(any()) } answers {
93
+ val component = firstArg<ComponentName>()
94
+ when (component.className) {
95
+ ".IconSwitcher_halloween" -> PackageManager.COMPONENT_ENABLED_STATE_ENABLED
96
+ else -> PackageManager.COMPONENT_ENABLED_STATE_DISABLED
97
+ }
98
+ }
99
+
100
+ val switcher = IconSwitcher(context, pm)
101
+ switcher.applyIcon("halloween", deferred = false)
102
+
103
+ val settings = settingsSlot.captured
104
+ assertEquals(2, settings.size)
105
+ assertTrue(
106
+ settings.any {
107
+ it.componentName!!.className == ".IconSwitcher_halloween" &&
108
+ it.enabledState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED &&
109
+ it.enabledFlags == PackageManager.DONT_KILL_APP
110
+ },
111
+ )
112
+ assertTrue(
113
+ settings.any {
114
+ it.componentName!!.className == ".IconSwitcher_default" &&
115
+ it.enabledState == PackageManager.COMPONENT_ENABLED_STATE_DISABLED &&
116
+ it.enabledFlags == PackageManager.DONT_KILL_APP
117
+ },
118
+ )
119
+ }
120
+
121
+ @Test
122
+ fun read_back_mismatch_reports_os_rejected() {
123
+ val pm = mockk<PackageManager>(relaxed = true)
124
+ val packageName = context.packageName
125
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(packageName)
126
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo(
127
+ applyOn = IconSwitcher.APPLY_ON_IMMEDIATE,
128
+ )
129
+ every { pm.getComponentEnabledSetting(any()) } returns PackageManager.COMPONENT_ENABLED_STATE_DISABLED
130
+
131
+ val switcher = IconSwitcher(context, pm)
132
+
133
+ assertThrows(ErrOsRejectedException::class.java) {
134
+ switcher.applyIcon("halloween", deferred = false)
135
+ }
136
+ }
137
+
138
+ @Test
139
+ fun cold_start_keeps_pending_without_touching_package_manager() {
140
+ val pm = mockk<PackageManager>(relaxed = true)
141
+ val packageName = context.packageName
142
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(packageName)
143
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo()
144
+
145
+ context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE)
146
+ .edit()
147
+ .putString(IconSwitcher.KEY_PENDING, "halloween")
148
+ .apply()
149
+
150
+ val switcher = IconSwitcher(context, pm)
151
+ switcher.onModuleCreate()
152
+
153
+ verify(exactly = 0) { pm.setComponentEnabledSetting(any(), any(), any()) }
154
+ verify(exactly = 0) { pm.setComponentEnabledSettings(any()) }
155
+ val prefs = context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE)
156
+ assertEquals("halloween", prefs.getString(IconSwitcher.KEY_PENDING, null))
157
+ }
158
+
159
+ @Test
160
+ fun cold_start_pm_mismatch_queues_stored_current_as_pending() {
161
+ val pm = mockk<PackageManager>(relaxed = true)
162
+ val packageName = context.packageName
163
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(packageName)
164
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo()
165
+ every { pm.getComponentEnabledSetting(any()) } answers {
166
+ val component = firstArg<ComponentName>()
167
+ if (component.className == ".IconSwitcher_default") {
168
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED
169
+ } else {
170
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED
171
+ }
172
+ }
173
+
174
+ context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE)
175
+ .edit()
176
+ .putString(IconSwitcher.KEY_CURRENT, "halloween")
177
+ .apply()
178
+
179
+ val switcher = IconSwitcher(context, pm)
180
+ switcher.onModuleCreate()
181
+
182
+ verify(exactly = 0) { pm.setComponentEnabledSetting(any(), any(), any()) }
183
+ verify(exactly = 0) { pm.setComponentEnabledSettings(any()) }
184
+ val prefs = context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE)
185
+ assertEquals("halloween", prefs.getString(IconSwitcher.KEY_PENDING, null))
186
+ }
187
+
188
+ @Test
189
+ fun on_stop_applies_pending_without_blocking_main_thread() {
190
+ val pm = mockk<PackageManager>(relaxed = true)
191
+ val packageName = context.packageName
192
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(packageName)
193
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo()
194
+
195
+ context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE)
196
+ .edit()
197
+ .putString(IconSwitcher.KEY_PENDING, "halloween")
198
+ .apply()
199
+
200
+ var lifecycleObserver: DefaultLifecycleObserver? = null
201
+ val prefs = context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE)
202
+ val switcher = IconSwitcher(
203
+ context = context,
204
+ packageManager = pm,
205
+ packageName = packageName,
206
+ prefs = prefs,
207
+ lifecycleRegistrar = { lifecycleObserver = it },
208
+ isInForeground = { false },
209
+ )
210
+
211
+ val applyLatch = CountDownLatch(1)
212
+ val settingsSlot = slot<List<PackageManager.ComponentEnabledSetting>>()
213
+ every { pm.setComponentEnabledSettings(capture(settingsSlot)) } answers {
214
+ applyLatch.countDown()
215
+ }
216
+ every { pm.getComponentEnabledSetting(any()) } answers {
217
+ val component = firstArg<ComponentName>()
218
+ if (component.className == ".IconSwitcher_halloween") {
219
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED
220
+ } else {
221
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED
222
+ }
223
+ }
224
+
225
+ switcher.onModuleCreate()
226
+ val owner = mockk<LifecycleOwner>(relaxed = true)
227
+ lifecycleObserver!!.onStop(owner)
228
+
229
+ assertTrue(applyLatch.await(2, TimeUnit.SECONDS))
230
+ assertTrue(
231
+ settingsSlot.captured.any {
232
+ it.componentName!!.className == ".IconSwitcher_halloween" &&
233
+ it.enabledFlags == PackageManager.DONT_KILL_APP
234
+ },
235
+ )
236
+ var pendingCleared = false
237
+ for (attempt in 0 until 50) {
238
+ if (!prefs.contains(IconSwitcher.KEY_PENDING)) {
239
+ pendingCleared = true
240
+ break
241
+ }
242
+ Thread.sleep(20)
243
+ }
244
+ assertTrue(pendingCleared)
245
+ assertEquals("halloween", prefs.getString(IconSwitcher.KEY_CURRENT, null))
246
+ }
247
+
248
+ @Test
249
+ fun apply_pending_none_when_nothing_pending() {
250
+ val pm = mockk<PackageManager>(relaxed = true)
251
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(context.packageName)
252
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo()
253
+
254
+ val switcher = switcherWithForeground(pm, inForeground = false)
255
+ val result = switcher.applyPendingNow()
256
+
257
+ assertEquals(IconSwitcher.STATUS_NONE, result.status)
258
+ verify(exactly = 0) { pm.setComponentEnabledSetting(any(), any(), any()) }
259
+ verify(exactly = 0) { pm.setComponentEnabledSettings(any()) }
260
+ }
261
+
262
+ @Test
263
+ fun apply_pending_in_foreground_throws_and_keeps_pending() {
264
+ val pm = mockk<PackageManager>(relaxed = true)
265
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(context.packageName)
266
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo()
267
+
268
+ val prefs = context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE)
269
+ prefs.edit().putString(IconSwitcher.KEY_PENDING, "halloween").apply()
270
+
271
+ val switcher = switcherWithForeground(pm, inForeground = true)
272
+
273
+ assertThrows(ErrForegroundException::class.java) {
274
+ switcher.applyPendingNow()
275
+ }
276
+ verify(exactly = 0) { pm.setComponentEnabledSetting(any(), any(), any()) }
277
+ verify(exactly = 0) { pm.setComponentEnabledSettings(any()) }
278
+ assertEquals("halloween", prefs.getString(IconSwitcher.KEY_PENDING, null))
279
+ }
280
+
281
+ @Test
282
+ fun apply_pending_in_background_applies_and_clears_pending() {
283
+ val pm = mockk<PackageManager>(relaxed = true)
284
+ val packageName = context.packageName
285
+ every { pm.getPackageInfo(any<String>(), any<Int>()) } returns robolectricPackageInfo(packageName)
286
+ every { pm.getApplicationInfo(any<String>(), any<Int>()) } returns robolectricApplicationInfo()
287
+ val settingsSlot = slot<List<PackageManager.ComponentEnabledSetting>>()
288
+ every { pm.setComponentEnabledSettings(capture(settingsSlot)) } returns Unit
289
+ every { pm.getComponentEnabledSetting(any()) } answers {
290
+ val component = firstArg<ComponentName>()
291
+ if (component.className == ".IconSwitcher_halloween") {
292
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED
293
+ } else {
294
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED
295
+ }
296
+ }
297
+
298
+ val prefs = context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE)
299
+ prefs.edit().putString(IconSwitcher.KEY_PENDING, "halloween").apply()
300
+
301
+ val switcher = switcherWithForeground(pm, inForeground = false)
302
+ val result = switcher.applyPendingNow()
303
+
304
+ assertEquals(IconSwitcher.STATUS_APPLIED, result.status)
305
+ assertTrue(
306
+ settingsSlot.captured.any {
307
+ it.componentName!!.className == ".IconSwitcher_halloween" &&
308
+ it.enabledFlags == PackageManager.DONT_KILL_APP
309
+ },
310
+ )
311
+ assertEquals(null, prefs.getString(IconSwitcher.KEY_PENDING, null))
312
+ assertEquals("halloween", prefs.getString(IconSwitcher.KEY_CURRENT, null))
313
+ }
314
+
315
+ @Test
316
+ fun on_module_create_from_background_thread_registers_lifecycle_observer() {
317
+ val switcher = IconSwitcher(context)
318
+ val error = java.util.concurrent.atomic.AtomicReference<Throwable?>(null)
319
+ val done = CountDownLatch(1)
320
+ Thread {
321
+ try {
322
+ switcher.onModuleCreate()
323
+ } catch (t: Throwable) {
324
+ error.set(t)
325
+ } finally {
326
+ done.countDown()
327
+ }
328
+ }.start()
329
+ assertTrue(done.await(2, TimeUnit.SECONDS))
330
+ assertEquals(null, error.get())
331
+ ShadowLooper.idleMainLooper()
332
+ }
333
+
334
+ private fun switcherWithForeground(
335
+ pm: PackageManager,
336
+ inForeground: Boolean,
337
+ prefs: android.content.SharedPreferences =
338
+ context.getSharedPreferences(IconSwitcher.PREFS_NAME, Context.MODE_PRIVATE),
339
+ ): IconSwitcher {
340
+ return IconSwitcher(
341
+ context = context,
342
+ packageManager = pm,
343
+ packageName = context.packageName,
344
+ prefs = prefs,
345
+ lifecycleRegistrar = { },
346
+ isInForeground = { inForeground },
347
+ )
348
+ }
349
+
350
+ private fun robolectricApplicationInfo(
351
+ applyOn: String = IconSwitcher.APPLY_ON_BACKGROUND,
352
+ icons: String = "default,halloween",
353
+ ): ApplicationInfo {
354
+ return ApplicationInfo().apply {
355
+ metaData = Bundle().apply {
356
+ putString(IconSwitcher.META_APPLY_ON, applyOn)
357
+ putString(IconSwitcher.META_ICONS, icons)
358
+ }
359
+ }
360
+ }
361
+
362
+ private fun robolectricPackageInfo(
363
+ packageName: String,
364
+ iconOrder: List<String> = listOf("default", "halloween"),
365
+ ): PackageInfo {
366
+ fun alias(classSuffix: String, iconName: String?, enabled: Boolean): ActivityInfo {
367
+ return ActivityInfo().apply {
368
+ name = classSuffix
369
+ this.packageName = packageName
370
+ this.enabled = enabled
371
+ if (iconName != null) {
372
+ metaData = Bundle().apply { putString(IconSwitcher.META_ICON, iconName) }
373
+ }
374
+ }
375
+ }
376
+
377
+ val activities = iconOrder.map { iconName ->
378
+ val suffix = when (iconName) {
379
+ "default" -> ".IconSwitcher_default"
380
+ "halloween" -> ".IconSwitcher_halloween"
381
+ "zebra" -> ".IconSwitcher_zebra"
382
+ else -> ".IconSwitcher_$iconName"
383
+ }
384
+ alias(suffix, iconName, iconName == "default")
385
+ } + alias(".UnmarkedAlias", null, false)
386
+
387
+ return PackageInfo().apply {
388
+ this.packageName = packageName
389
+ this.activities = activities.toTypedArray()
390
+ }
391
+ }
392
+ }
package/app.plugin.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require('./plugin/build');
@@ -0,0 +1,49 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ FOUNDATION_EXPORT NSString *const ISIconSwitcherErrorDomain;
6
+
7
+ typedef NS_ERROR_ENUM(ISIconSwitcherErrorDomain, ISIconSwitcherErrorCode){
8
+ ISIconSwitcherErrorCodeUnsupported = 1,
9
+ ISIconSwitcherErrorCodeUnknownIcon = 2,
10
+ ISIconSwitcherErrorCodeOSRejected = 3,
11
+ };
12
+
13
+ /// Injectable abstraction so `ISIconSwitcher` is testable without `UIApplication`.
14
+ @protocol ISAlternateIconProviding <NSObject>
15
+
16
+ @property (nonatomic, readonly) BOOL supportsAlternateIcons;
17
+ @property (nonatomic, readonly, nullable) NSString *alternateIconName;
18
+ - (void)setAlternateIconName:(nullable NSString *)iconName
19
+ completionHandler:(void (^_Nullable)(NSError *_Nullable error))completionHandler;
20
+
21
+ @end
22
+
23
+ typedef void (^ISMainScheduler)(void (^block)(void));
24
+
25
+ @interface ISIconSwitcher : NSObject
26
+
27
+ @property (class, nonatomic, readonly) NSString *alternateIconPrefix;
28
+ @property (class, nonatomic, readonly) NSString *iconsPlistKey;
29
+
30
+ - (instancetype)init NS_UNAVAILABLE;
31
+ + (instancetype)new NS_UNAVAILABLE;
32
+ - (instancetype)initWithProvider:(id<ISAlternateIconProviding>)provider
33
+ bundle:(NSBundle *)bundle
34
+ scheduleOnMain:(ISMainScheduler)scheduleOnMain NS_DESIGNATED_INITIALIZER;
35
+ + (instancetype)defaultSwitcher;
36
+
37
+ @property (nonatomic, readonly, copy) NSArray<NSString *> *iconNames;
38
+ @property (nonatomic, readonly) BOOL isSupported;
39
+
40
+ - (NSString *)currentIconName;
41
+ - (void)setIconName:(NSString *)name
42
+ completion:(void (^)(NSError *_Nullable error))completion;
43
+
44
+ + (NSString *)iconNameFromAlternateIconName:(nullable NSString *)alternate;
45
+ + (nullable NSString *)alternateIconNameFromIconName:(NSString *)name;
46
+
47
+ @end
48
+
49
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,205 @@
1
+ #import "ISIconSwitcher.h"
2
+
3
+ #import <UIKit/UIKit.h>
4
+
5
+ NSString *const ISIconSwitcherErrorDomain = @"com.iconswitcher.IconSwitcher";
6
+
7
+ @interface ISUIApplicationAlternateIconProvider : NSObject <ISAlternateIconProviding>
8
+ @end
9
+
10
+ @implementation ISUIApplicationAlternateIconProvider
11
+
12
+ + (instancetype)shared
13
+ {
14
+ static ISUIApplicationAlternateIconProvider *instance;
15
+ static dispatch_once_t onceToken;
16
+ dispatch_once(&onceToken, ^{
17
+ instance = [ISUIApplicationAlternateIconProvider new];
18
+ });
19
+ return instance;
20
+ }
21
+
22
+ - (BOOL)supportsAlternateIcons
23
+ {
24
+ return UIApplication.sharedApplication.supportsAlternateIcons;
25
+ }
26
+
27
+ - (NSString *)alternateIconName
28
+ {
29
+ return UIApplication.sharedApplication.alternateIconName;
30
+ }
31
+
32
+ - (void)setAlternateIconName:(NSString *)iconName
33
+ completionHandler:(void (^)(NSError *_Nullable))completionHandler
34
+ {
35
+ [UIApplication.sharedApplication setAlternateIconName:iconName
36
+ completionHandler:completionHandler];
37
+ }
38
+
39
+ @end
40
+
41
+ static id _runOnMain(id (^work)(void))
42
+ {
43
+ if ([NSThread isMainThread]) {
44
+ return work();
45
+ }
46
+ __block id result;
47
+ dispatch_sync(dispatch_get_main_queue(), ^{
48
+ result = work();
49
+ });
50
+ return result;
51
+ }
52
+
53
+ @implementation ISIconSwitcher {
54
+ id<ISAlternateIconProviding> _provider;
55
+ NSBundle *_bundle;
56
+ ISMainScheduler _scheduleOnMain;
57
+ }
58
+
59
+ + (NSString *)alternateIconPrefix
60
+ {
61
+ return @"AppIcon-";
62
+ }
63
+
64
+ + (NSString *)iconsPlistKey
65
+ {
66
+ return @"IconSwitcherIcons";
67
+ }
68
+
69
+ - (instancetype)initWithProvider:(id<ISAlternateIconProviding>)provider
70
+ bundle:(NSBundle *)bundle
71
+ scheduleOnMain:(ISMainScheduler)scheduleOnMain
72
+ {
73
+ if (self = [super init]) {
74
+ _provider = provider;
75
+ _bundle = bundle;
76
+ _scheduleOnMain = [scheduleOnMain copy];
77
+ }
78
+ return self;
79
+ }
80
+
81
+ + (instancetype)defaultSwitcher
82
+ {
83
+ return [[self alloc] initWithProvider:[ISUIApplicationAlternateIconProvider shared]
84
+ bundle:NSBundle.mainBundle
85
+ scheduleOnMain:^(void (^block)(void)) {
86
+ dispatch_async(dispatch_get_main_queue(), block);
87
+ }];
88
+ }
89
+
90
+ - (NSArray<NSString *> *)iconNames
91
+ {
92
+ id raw = [_bundle objectForInfoDictionaryKey:self.class.iconsPlistKey];
93
+ if ([raw isKindOfClass:[NSArray class]]) {
94
+ NSArray *names = (NSArray *)raw;
95
+ if (names.count > 0) {
96
+ return names;
97
+ }
98
+ }
99
+ return @[ @"default" ];
100
+ }
101
+
102
+ - (BOOL)isSupported
103
+ {
104
+ return [_runOnMain(^{
105
+ return @(_provider.supportsAlternateIcons);
106
+ }) boolValue];
107
+ }
108
+
109
+ - (NSString *)currentIconName
110
+ {
111
+ return [_runOnMain(^{
112
+ return [self.class iconNameFromAlternateIconName:_provider.alternateIconName];
113
+ }) copy];
114
+ }
115
+
116
+ - (void)setIconName:(NSString *)name completion:(void (^)(NSError *_Nullable))completion
117
+ {
118
+ if (![_runOnMain(^{
119
+ return @(_provider.supportsAlternateIcons);
120
+ }) boolValue]) {
121
+ completion([NSError errorWithDomain:ISIconSwitcherErrorDomain
122
+ code:ISIconSwitcherErrorCodeUnsupported
123
+ userInfo:@{
124
+ NSLocalizedDescriptionKey :
125
+ @"Alternate app icons are not supported on this device.",
126
+ }]);
127
+ return;
128
+ }
129
+
130
+ if (![self.iconNames containsObject:name]) {
131
+ completion([NSError errorWithDomain:ISIconSwitcherErrorDomain
132
+ code:ISIconSwitcherErrorCodeUnknownIcon
133
+ userInfo:@{
134
+ NSLocalizedDescriptionKey :
135
+ @"The requested icon name is not bundled in this app.",
136
+ }]);
137
+ return;
138
+ }
139
+
140
+ if ([[self currentIconName] isEqualToString:name]) {
141
+ completion(nil);
142
+ return;
143
+ }
144
+
145
+ NSString *targetAlternate = [self.class alternateIconNameFromIconName:name];
146
+ ISMainScheduler scheduleOnMain = _scheduleOnMain;
147
+ id<ISAlternateIconProviding> provider = _provider;
148
+ // The provider holds the completion block until UIKit calls it, so the read-back must not
149
+ // capture it strongly.
150
+ __weak id<ISAlternateIconProviding> weakProvider = provider;
151
+
152
+ scheduleOnMain(^{
153
+ [provider setAlternateIconName:targetAlternate
154
+ completionHandler:^(NSError *_Nullable error) {
155
+ scheduleOnMain(^{
156
+ if (error != nil) {
157
+ completion([NSError errorWithDomain:ISIconSwitcherErrorDomain
158
+ code:ISIconSwitcherErrorCodeOSRejected
159
+ userInfo:@{
160
+ NSLocalizedDescriptionKey : error.localizedDescription,
161
+ }]);
162
+ return;
163
+ }
164
+
165
+ NSString *actualAlternate = weakProvider.alternateIconName;
166
+ BOOL matches = (targetAlternate == nil && actualAlternate == nil) ||
167
+ [actualAlternate isEqualToString:targetAlternate];
168
+ if (!matches) {
169
+ completion([NSError
170
+ errorWithDomain:ISIconSwitcherErrorDomain
171
+ code:ISIconSwitcherErrorCodeOSRejected
172
+ userInfo:@{
173
+ NSLocalizedDescriptionKey :
174
+ @"Alternate icon name mismatch after apply",
175
+ }]);
176
+ return;
177
+ }
178
+
179
+ completion(nil);
180
+ });
181
+ }];
182
+ });
183
+ }
184
+
185
+ + (NSString *)iconNameFromAlternateIconName:(NSString *)alternate
186
+ {
187
+ if (alternate == nil) {
188
+ return @"default";
189
+ }
190
+ NSString *prefix = self.alternateIconPrefix;
191
+ if ([alternate hasPrefix:prefix]) {
192
+ return [alternate substringFromIndex:prefix.length];
193
+ }
194
+ return alternate;
195
+ }
196
+
197
+ + (NSString *)alternateIconNameFromIconName:(NSString *)name
198
+ {
199
+ if ([name isEqualToString:@"default"]) {
200
+ return nil;
201
+ }
202
+ return [self.alternateIconPrefix stringByAppendingString:name];
203
+ }
204
+
205
+ @end
@@ -0,0 +1,5 @@
1
+ #import <IconSwitcherSpec/IconSwitcherSpec.h>
2
+
3
+ @interface IconSwitcher : NSObject <NativeIconSwitcherSpec>
4
+
5
+ @end