react-native-icon-switcher 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -5
- 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/lib/module/remote/index.js +35 -7
- package/lib/module/remote/index.js.map +1 -1
- package/lib/typescript/src/remote/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/remote/index.ts +37 -7
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ Switch the app icon at runtime on React Native and Expo from one config file, wi
|
|
|
22
22
|
npx expo install react-native-icon-switcher
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Add the config plugin in `app.json` / `app.config.ts`
|
|
25
|
+
Add the config plugin in `app.json` / `app.config.ts` and pass the icon config as its options (same shape as [Config](#config)):
|
|
26
26
|
|
|
27
27
|
```ts
|
|
28
28
|
export default {
|
|
@@ -44,6 +44,20 @@ export default {
|
|
|
44
44
|
|
|
45
45
|
Then run `npx expo prebuild` or use a development build. Expo Go is not supported.
|
|
46
46
|
|
|
47
|
+
The plugin only reads its inline options; it does not look for `icon-switcher.config.ts` on its own. To keep the config in its own file (for example to share it with the `sync` CLI), import it in `app.config.ts` with the explicit extension:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import iconSwitcherConfig from './icon-switcher.config.ts';
|
|
51
|
+
|
|
52
|
+
export default {
|
|
53
|
+
expo: {
|
|
54
|
+
plugins: [['react-native-icon-switcher', iconSwitcherConfig]],
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Expo transpiles `app.config.ts` but not the files it imports, so this relies on Node's built-in TypeScript type stripping (Node 22.18 or later). On older Node versions, write the config as `icon-switcher.config.js` instead.
|
|
60
|
+
|
|
47
61
|
### Bare React Native
|
|
48
62
|
|
|
49
63
|
```sh
|
|
@@ -61,13 +75,27 @@ npx react-native-icon-switcher sync
|
|
|
61
75
|
|
|
62
76
|
Use `npx react-native-icon-switcher sync --check` in CI to fail when generated native files or `icon-switcher.d.ts` are out of date.
|
|
63
77
|
|
|
78
|
+
```sh
|
|
79
|
+
react-native-icon-switcher sync [--check] [--project-root <dir>] [--config <path>]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
| Flag | Default | Description |
|
|
83
|
+
| --- | --- | --- |
|
|
84
|
+
| `--check` | off | Write nothing; print each out-of-date path and exit `1` if any. |
|
|
85
|
+
| `--project-root <dir>` | current directory | App root containing `ios/` and `android/` (useful in monorepos). |
|
|
86
|
+
| `--config <path>` | first of `icon-switcher.config.{ts,js,mjs,cjs}` in the project root | Config file to load. TypeScript is loaded directly, with no compile step. |
|
|
87
|
+
|
|
88
|
+
Exit codes: `0` success (or no drift with `--check`), `1` drift found by `--check`, `2` invalid arguments, config errors, or an unexpected project layout. Without `--check` it prints `updated N files, unchanged M`.
|
|
89
|
+
|
|
64
90
|
**No `expo` package is required** for bare apps: this library is a TurboModule with a standalone `sync` CLI. You only need `expo` if you opt into the Expo config plugin workflow.
|
|
65
91
|
|
|
66
92
|
See `example/` (Expo) and `example-bare/` (bare CLI + `sync`) in this repo.
|
|
67
93
|
|
|
68
94
|
## Config
|
|
69
95
|
|
|
70
|
-
Bare apps use `icon-switcher.config.ts` at the project root. Expo apps
|
|
96
|
+
Bare apps use `icon-switcher.config.ts` at the project root. Expo apps pass the same shape as plugin options (or import that file, see [Expo](#expo)).
|
|
97
|
+
|
|
98
|
+
`defineIcons` has no runtime effect: it returns its argument unchanged and exists so TypeScript checks the object as you write it (misspelled keys, wrong `applyOn` values, a missing adaptive `background`) and offers autocomplete. Value rules are checked when `sync` or `expo prebuild` runs, and every problem is reported at once.
|
|
71
99
|
|
|
72
100
|
```ts
|
|
73
101
|
import { defineIcons } from 'react-native-icon-switcher/config';
|
|
@@ -105,12 +133,31 @@ Per-icon `ios` / `android` fields accept a path or structured variants (`IosIcon
|
|
|
105
133
|
|
|
106
134
|
Icons with no art for a platform are omitted from that platform's generated outputs.
|
|
107
135
|
|
|
136
|
+
**Validation:** `sync` and the Expo plugin fail if `icons` is empty, a name does not match `/^[a-z][a-z0-9_]*$/` or uses `default`, a colour is not `#RGB` / `#RRGGBB`, an image file does not exist, or Expo's default icon and `default` point at different files.
|
|
137
|
+
|
|
108
138
|
**Android reserved names:** names may not be `round`, `foreground`, `background`, or `monochrome`, nor end in `_round`, `_foreground`, `_background`, or `_monochrome`.
|
|
109
139
|
|
|
140
|
+
**Android adaptive icons:** layers are 108×108 dp, but launchers show only the central 72×72 dp and then mask it (circle, squircle, …). Keep logos and text inside that centre, about the middle two thirds of the image. To fit artwork that fills its square, scale it into the centre of the `background` image on a matching colour and use a transparent `foreground`.
|
|
141
|
+
|
|
110
142
|
**Legacy Android icons (API 24–25):** besides the adaptive layers and `mipmap-anydpi-v26` XML, `sync` and the plugin write `ic_launcher*.png` the way Android Studio's Image Asset tool does: the visible centre of an adaptive icon (72 dp of the 108 dp layers; a plain path uses the whole image) in a 38 dp rounded square or a 44 dp circle, with a soft drop shadow, on a transparent 48 dp canvas.
|
|
111
143
|
|
|
144
|
+
**Variants (experimental):** iOS `dark` / `tinted` and Android `monochrome` (themed icons, Android 13+) are generated when set, but have not yet been verified on devices.
|
|
145
|
+
|
|
146
|
+
### What `sync` and the plugin generate
|
|
147
|
+
|
|
148
|
+
| | iOS | Android |
|
|
149
|
+
| --- | --- | --- |
|
|
150
|
+
| Images | `AppIcon-<name>.appiconset` in `Images.xcassets` (1024 px, flattened onto `ios.flattenOn`) | `mipmap-*/ic_launcher_<name>*` in five densities, plus `mipmap-anydpi-v26` adaptive XML |
|
|
151
|
+
| Registration | `ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES` and `ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS` build settings | One `activity-alias` per icon, `.IconSwitcher_<name>`; `MainActivity` loses its launcher filter |
|
|
152
|
+
| Runtime list | `IconSwitcherIcons` array in `Info.plist` | `com.iconswitcher.*` meta-data in `AndroidManifest.xml` |
|
|
153
|
+
| Types | `icon-switcher.d.ts` at the project root | same file |
|
|
154
|
+
|
|
155
|
+
Xcode generates `CFBundleIcons` from the build settings, so the library does not write it. Other content in these files is left as it is: comments, formatting, and the quoting CocoaPods applies to the pbxproj, so `sync --check` stays clean after `pod install`. In bare apps, commit the generated files. With Expo prebuild, commit `icon-switcher.d.ts`; native folders follow your usual prebuild policy.
|
|
156
|
+
|
|
112
157
|
**Managed iOS icon sets:** when `sync` or the plugin rewrites an `.appiconset` it owns, it deletes files in that folder that the new `Contents.json` does not reference (`sync --check` reports them). Other icon sets are left alone.
|
|
113
158
|
|
|
159
|
+
**Generated images across machines:** image resampling rounds slightly differently on different CPUs (for example an Apple silicon Mac and x86 Linux CI). `sync` treats an existing PNG whose pixels differ from the regenerated one by at most 3 levels per channel as unchanged, so `sync --check` stays green and files are not rewritten.
|
|
160
|
+
|
|
114
161
|
## Usage
|
|
115
162
|
|
|
116
163
|
```ts
|
|
@@ -133,7 +180,7 @@ if (IconSwitcher.isSupported) {
|
|
|
133
180
|
|
|
134
181
|
| Method | Returns | Notes |
|
|
135
182
|
| --- | --- | --- |
|
|
136
|
-
| `IconSwitcher.isSupported` | `boolean` | `false` when the TurboModule is missing (web, Expo Go, unlink)
|
|
183
|
+
| `IconSwitcher.isSupported` | `boolean` | `false` when the TurboModule is missing (web, Expo Go, unlink), the device brand is skipped, or iOS reports `supportsAlternateIcons` as `false`. |
|
|
137
184
|
| `IconSwitcher.list()` | `IconName[]` | Bundled icon names; `default` first. |
|
|
138
185
|
| `IconSwitcher.getCurrent()` | `Promise<IconName>` | |
|
|
139
186
|
| `IconSwitcher.set(name)` | `Promise<{ status: 'applied' \| 'pending' }>` | Android with `applyOn: 'background'` → `pending` until background or `applyPending()`. |
|
|
@@ -181,7 +228,7 @@ const unsubscribe = await syncIcon({
|
|
|
181
228
|
const res = await fetch('https://api.example.com/icon');
|
|
182
229
|
return (await res.json()).iconName as string;
|
|
183
230
|
},
|
|
184
|
-
storage:
|
|
231
|
+
storage: AsyncStorage, // optional; default: in-memory
|
|
185
232
|
retryAfterMs: 24 * 60 * 60 * 1000, // skip re-apply after failure
|
|
186
233
|
onError: (err) => console.warn(err),
|
|
187
234
|
});
|
|
@@ -192,6 +239,10 @@ const unsubscribe = await syncIcon({
|
|
|
192
239
|
|
|
193
240
|
On **iOS**, the helper runs when the app is `active` and subscribes to `AppState`. On **Android**, it runs once when called (`set` may still return `pending`).
|
|
194
241
|
|
|
242
|
+
Each run does nothing when `fetchChoice` returns `null`, `undefined`, `''`, or a name not in `list()`, or when the choice is already current. A `pending` result is not recorded as applied.
|
|
243
|
+
|
|
244
|
+
`storage` is any object with async `getItem(key): Promise<string | null>` and `setItem(key, value): Promise<void>` (`SyncIconStorage`), such as `@react-native-async-storage/async-storage`. The helper uses the keys `icon-switcher:lastApplied` and `icon-switcher:lastFailed`. With the default in-memory storage, the retry block lasts only as long as that `syncIcon` call, so a restart retries straight away.
|
|
245
|
+
|
|
195
246
|
## Testing with Jest
|
|
196
247
|
|
|
197
248
|
Ship a CommonJS mock that never loads native code:
|
|
@@ -242,11 +293,12 @@ it('applies a pending switch', async () => {
|
|
|
242
293
|
|
|
243
294
|
Deferred apply, task recreation when aliases toggle, the `applyPending()` foreground guard, cold-start behaviour, and launcher shortcuts are documented in [docs/guides/android.md](docs/guides/android.md).
|
|
244
295
|
|
|
245
|
-
Summary: with default `applyOn: 'background'`, `set()` returns `pending` and the launcher updates
|
|
296
|
+
Summary: with default `applyOn: 'background'`, `set()` returns `pending` and the launcher updates when the last screen stops (including swipe-away from Recents) or after you background the app; listening starts at process start via AndroidX Startup. The process usually survives but the next open may look like a cold start. `applyPending()` in the foreground is refused with `foreground`.
|
|
246
297
|
|
|
247
298
|
## Requirements
|
|
248
299
|
|
|
249
300
|
- **New Architecture (TurboModule) required** — React Native **≥ 0.82** (developed with **0.88**).
|
|
301
|
+
- **iOS 15.1+** (React Native's `min_ios_version_supported`) and **Android minSdk 24**. On Android the module adds `androidx.lifecycle:lifecycle-process` to detect when the app goes to the background.
|
|
250
302
|
- **Expo SDK ≥ 52** when using the config plugin (optional peer; developed with **SDK 58**).
|
|
251
303
|
- **Expo Go is not supported** — use a development build or bare native project.
|
|
252
304
|
|
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
|
}
|
|
@@ -48,6 +48,16 @@ function _isFailedBlocked(failed, choice, retryAfterMs) {
|
|
|
48
48
|
return Date.now() - failed.at < retryAfterMs;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Shared by every syncIcon instance: callers restart syncIcon when their inputs change, and an
|
|
52
|
+
// older instance's run can still be waiting on the OS (iOS shows an alert per set call). Running
|
|
53
|
+
// one at a time lets the next run see the icon the previous one applied.
|
|
54
|
+
let _queue = Promise.resolve();
|
|
55
|
+
function _enqueue(task) {
|
|
56
|
+
const next = _queue.then(task, task);
|
|
57
|
+
_queue = next.catch(() => {});
|
|
58
|
+
return next;
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
/**
|
|
52
62
|
* Fetches the remote icon choice, validates it, and applies it via {@link IconSwitcher}.
|
|
53
63
|
* iOS: runs while the app is `active`. Android: calls `set` immediately (native may defer).
|
|
@@ -64,13 +74,17 @@ export async function syncIcon(options) {
|
|
|
64
74
|
}
|
|
65
75
|
};
|
|
66
76
|
let inFlight = false;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
inFlight = true;
|
|
77
|
+
let stopped = false;
|
|
78
|
+
const _apply = async () => {
|
|
72
79
|
try {
|
|
80
|
+
// Unsubscribed while queued: the caller has moved on to a newer syncIcon.
|
|
81
|
+
if (stopped) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
73
84
|
const choice = await options.fetchChoice();
|
|
85
|
+
if (stopped) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
74
88
|
if (choice == null || choice === '') {
|
|
75
89
|
return;
|
|
76
90
|
}
|
|
@@ -103,6 +117,15 @@ export async function syncIcon(options) {
|
|
|
103
117
|
}
|
|
104
118
|
} catch (error) {
|
|
105
119
|
reportError(error);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const run = async () => {
|
|
123
|
+
if (inFlight || stopped) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
inFlight = true;
|
|
127
|
+
try {
|
|
128
|
+
await _enqueue(_apply);
|
|
106
129
|
} finally {
|
|
107
130
|
inFlight = false;
|
|
108
131
|
}
|
|
@@ -119,9 +142,14 @@ export async function syncIcon(options) {
|
|
|
119
142
|
}
|
|
120
143
|
});
|
|
121
144
|
runWhenActive();
|
|
122
|
-
return () =>
|
|
145
|
+
return () => {
|
|
146
|
+
stopped = true;
|
|
147
|
+
subscription.remove();
|
|
148
|
+
};
|
|
123
149
|
}
|
|
124
150
|
await run();
|
|
125
|
-
return () => {
|
|
151
|
+
return () => {
|
|
152
|
+
stopped = true;
|
|
153
|
+
};
|
|
126
154
|
}
|
|
127
155
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["AppState","Platform","IconSwitcher","STORAGE_KEY_APPLIED","STORAGE_KEY_FAILED","DEFAULT_RETRY_AFTER_MS","_createMemoryStorage","data","Map","getItem","key","get","setItem","value","set","_encodeFailed","name","JSON","stringify","at","Date","now","_decodeFailed","raw","length","parsed","parse","_isFailedBlocked","failed","choice","retryAfterMs","syncIcon","options","storage","reportError","error","onError","inFlight","
|
|
1
|
+
{"version":3,"names":["AppState","Platform","IconSwitcher","STORAGE_KEY_APPLIED","STORAGE_KEY_FAILED","DEFAULT_RETRY_AFTER_MS","_createMemoryStorage","data","Map","getItem","key","get","setItem","value","set","_encodeFailed","name","JSON","stringify","at","Date","now","_decodeFailed","raw","length","parsed","parse","_isFailedBlocked","failed","choice","retryAfterMs","_queue","Promise","resolve","_enqueue","task","next","then","catch","syncIcon","options","storage","reportError","error","onError","inFlight","stopped","_apply","fetchChoice","list","includes","isSupported","failedRaw","current","getCurrent","result","status","run","runWhenActive","currentState","OS","subscription","addEventListener","state","remove"],"sourceRoot":"../../../src","sources":["remote/index.ts"],"mappings":"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","ignoreList":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/remote/index.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC7C,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpD;AAED,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IACtD,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B,4EAA4E;IAC5E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/remote/index.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC7C,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpD;AAED,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IACtD,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B,4EAA4E;IAC5E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC;AAwDD;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAkG5E"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-icon-switcher",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Switch the app icon at runtime on React Native and Expo from one config file, with the native wiring generated for you",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"types": "./lib/typescript/src/index.d.ts",
|
package/src/remote/index.ts
CHANGED
|
@@ -64,6 +64,17 @@ function _isFailedBlocked(
|
|
|
64
64
|
return Date.now() - failed.at < retryAfterMs;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Shared by every syncIcon instance: callers restart syncIcon when their inputs change, and an
|
|
68
|
+
// older instance's run can still be waiting on the OS (iOS shows an alert per set call). Running
|
|
69
|
+
// one at a time lets the next run see the icon the previous one applied.
|
|
70
|
+
let _queue: Promise<void> = Promise.resolve();
|
|
71
|
+
|
|
72
|
+
function _enqueue(task: () => Promise<void>): Promise<void> {
|
|
73
|
+
const next = _queue.then(task, task);
|
|
74
|
+
_queue = next.catch(() => {});
|
|
75
|
+
return next;
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
/**
|
|
68
79
|
* Fetches the remote icon choice, validates it, and applies it via {@link IconSwitcher}.
|
|
69
80
|
* iOS: runs while the app is `active`. Android: calls `set` immediately (native may defer).
|
|
@@ -81,14 +92,18 @@ export async function syncIcon(options: SyncIconOptions): Promise<() => void> {
|
|
|
81
92
|
};
|
|
82
93
|
|
|
83
94
|
let inFlight = false;
|
|
95
|
+
let stopped = false;
|
|
84
96
|
|
|
85
|
-
const
|
|
86
|
-
if (inFlight) {
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
inFlight = true;
|
|
97
|
+
const _apply = async (): Promise<void> => {
|
|
90
98
|
try {
|
|
99
|
+
// Unsubscribed while queued: the caller has moved on to a newer syncIcon.
|
|
100
|
+
if (stopped) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
91
103
|
const choice = await options.fetchChoice();
|
|
104
|
+
if (stopped) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
92
107
|
if (choice == null || choice === '') {
|
|
93
108
|
return;
|
|
94
109
|
}
|
|
@@ -124,6 +139,16 @@ export async function syncIcon(options: SyncIconOptions): Promise<() => void> {
|
|
|
124
139
|
}
|
|
125
140
|
} catch (error) {
|
|
126
141
|
reportError(error);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const run = async (): Promise<void> => {
|
|
146
|
+
if (inFlight || stopped) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
inFlight = true;
|
|
150
|
+
try {
|
|
151
|
+
await _enqueue(_apply);
|
|
127
152
|
} finally {
|
|
128
153
|
inFlight = false;
|
|
129
154
|
}
|
|
@@ -142,9 +167,14 @@ export async function syncIcon(options: SyncIconOptions): Promise<() => void> {
|
|
|
142
167
|
}
|
|
143
168
|
});
|
|
144
169
|
runWhenActive();
|
|
145
|
-
return () =>
|
|
170
|
+
return () => {
|
|
171
|
+
stopped = true;
|
|
172
|
+
subscription.remove();
|
|
173
|
+
};
|
|
146
174
|
}
|
|
147
175
|
|
|
148
176
|
await run();
|
|
149
|
-
return () => {
|
|
177
|
+
return () => {
|
|
178
|
+
stopped = true;
|
|
179
|
+
};
|
|
150
180
|
}
|