react-native-update 10.56.0 → 10.57.0

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.
@@ -71,6 +71,52 @@ final class NativeCheckOrchestrator {
71
71
  // (markJsCheckCompleted). Process-scoped by design: the next launch
72
72
  // starts with no signal and the cold-start round runs again.
73
73
  private static volatile String sJsCompletedConfig;
74
+ // Published after the launch rollback snapshot, before host calls are accepted.
75
+ private static volatile boolean nativeReady;
76
+ private static volatile NativeUpdateResult roundResult =
77
+ NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed");
78
+ private static volatile long roundGeneration = -1;
79
+ private static volatile long roundConfigGeneration = -1;
80
+ private static volatile String roundConfigJson;
81
+
82
+ /** Blocking only on the host API's worker; never call on the UI thread. */
83
+ static NativeUpdateResult checkAndUpdate(UpdateContext context) throws InterruptedException {
84
+ if (UpdateContext.DEBUG) {
85
+ return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "debug");
86
+ }
87
+ if (!nativeReady || sContext != context || !context.getIsUsingBundleUrl()) {
88
+ return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_initialized");
89
+ }
90
+ String configJson = context.getKv(KEY_CONFIG);
91
+ if (configJson == null || configJson.isEmpty()) {
92
+ return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_configured");
93
+ }
94
+ try {
95
+ JSONObject config = new JSONObject(configJson);
96
+ if (config.optBoolean("disabled", false)) {
97
+ return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "disabled");
98
+ }
99
+ if (config.optString("appKey", "").isEmpty()) {
100
+ return NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config");
101
+ }
102
+ } catch (JSONException e) {
103
+ return NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config");
104
+ }
105
+ startRound(0);
106
+ if (!roundStarted.get()) {
107
+ return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "config_changed");
108
+ }
109
+ roundDone.await();
110
+ if (roundConfigGeneration != UpdateContext.getNativeConfigGeneration()
111
+ || !configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) {
112
+ return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "config_changed");
113
+ }
114
+ if (roundGeneration != UpdateContext.getResetGeneration()) {
115
+ return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset");
116
+ }
117
+ return roundResult;
118
+ }
119
+
74
120
 
75
121
  static void markJsCheckCompleted(String config) {
76
122
  sJsCompletedConfig = config;
@@ -99,6 +145,7 @@ final class NativeCheckOrchestrator {
99
145
  }
100
146
  sContext = context;
101
147
  sLaunchRolledBackVersion = launchRolledBackVersion;
148
+ nativeReady = true;
102
149
  // The crash-hold rescue shares the orchestrator's rollout gate: no
103
150
  // persisted config, no handler (§11.3).
104
151
  if (context.getKv(KEY_CONFIG) != null) {
@@ -142,7 +189,36 @@ final class NativeCheckOrchestrator {
142
189
  * started it yet. deadlineNanos > 0 (crash rescue) caps every HTTP call
143
190
  * and download phase to the remaining budget.
144
191
  */
192
+ private static boolean hasRunnableConfig(UpdateContext context) {
193
+ if (context == null) {
194
+ return false;
195
+ }
196
+ try {
197
+ String json = context.getKv(KEY_CONFIG);
198
+ if (json == null) {
199
+ return false;
200
+ }
201
+ JSONObject config = new JSONObject(json);
202
+ return !config.optBoolean("disabled", false)
203
+ && config.opt("appKey") instanceof String
204
+ && !config.getString("appKey").trim().isEmpty();
205
+ } catch (JSONException e) {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ static void onConfigured(UpdateContext context) {
211
+ if (nativeReady && sContext == context && hasRunnableConfig(context)) {
212
+ CrashRescue.install();
213
+ }
214
+ }
215
+
145
216
  private static void startRound(long deadlineNanos) {
217
+ // An automatic check before first-run provisioning must not consume
218
+ // the process's only round. Hosts may configure later in this launch.
219
+ if (!hasRunnableConfig(sContext)) {
220
+ return;
221
+ }
146
222
  if (!roundStarted.compareAndSet(false, true)) {
147
223
  return;
148
224
  }
@@ -150,6 +226,7 @@ final class NativeCheckOrchestrator {
150
226
  runOnce(sContext, sLaunchRolledBackVersion, deadlineNanos);
151
227
  } catch (Throwable e) {
152
228
  Log.w(UpdateContext.TAG, "native check failed: " + e);
229
+ roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "internal_error");
153
230
  } finally {
154
231
  roundCompleted = true;
155
232
  roundDone.countDown();
@@ -170,7 +247,7 @@ final class NativeCheckOrchestrator {
170
247
  }
171
248
  crashRescueActive = true;
172
249
  startRound(deadlineNanos);
173
- if (!roundCompleted) {
250
+ if (roundStarted.get() && !roundCompleted) {
174
251
  long remainingNanos = deadlineNanos - System.nanoTime();
175
252
  if (remainingNanos > 0) {
176
253
  try {
@@ -223,32 +300,33 @@ final class NativeCheckOrchestrator {
223
300
  String launchRolledBackVersion,
224
301
  long deadlineNanos
225
302
  ) throws JSONException {
226
- // Sampled before any IO: a reset landing while this round runs must
227
- // win over the round's decision.
228
303
  final long resetGeneration = UpdateContext.getResetGeneration();
304
+ roundGeneration = resetGeneration;
305
+ roundConfigGeneration = UpdateContext.getNativeConfigGeneration();
306
+ roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed");
229
307
  String configJson = context.getKv(KEY_CONFIG);
308
+ roundConfigJson = configJson;
230
309
  if (configJson == null || configJson.isEmpty()) {
231
- // No persisted config (old integration / first ever launch): the
232
- // native check silently does not run — this is the rollout gate.
310
+ roundResult = NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_configured");
233
311
  return;
234
312
  }
235
313
  JSONObject config;
236
314
  try {
237
315
  config = new JSONObject(configJson);
238
316
  } catch (JSONException e) {
317
+ roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config");
239
318
  return;
240
319
  }
241
320
  if (config.optBoolean("disabled", false)) {
321
+ roundResult = NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "disabled");
242
322
  return;
243
323
  }
244
324
  String appKey = config.optString("appKey", "");
245
325
  if (appKey.isEmpty()) {
326
+ roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config");
246
327
  return;
247
328
  }
248
- // From here on the round does real work: leave the breadcrumb that
249
- // the next launch reads to skip its 5s delay if we die mid-round.
250
- // Best-effort: a lost breadcrumb costs one 5s delay, it must not
251
- // abort the rescue round itself.
329
+ // Keep the existing interrupted-round breadcrumb and reset generation.
252
330
  try {
253
331
  context.setKv(KEY_ROUND_INCOMPLETE, "1");
254
332
  } catch (IllegalStateException ignored) {
@@ -323,6 +401,7 @@ final class NativeCheckOrchestrator {
323
401
 
324
402
  String body = NativeUpdateFlow.buildCheckRequestBody(input.toString());
325
403
  if (body == null) {
404
+ roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_request");
326
405
  return;
327
406
  }
328
407
 
@@ -339,19 +418,24 @@ final class NativeCheckOrchestrator {
339
418
  String decisionJson = NativeUpdateFlow.handleCheckResponse(
340
419
  responseText, identity.toString(), config.optString("afterDownload", ""));
341
420
  if (decisionJson == null) {
421
+ roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_response");
342
422
  return;
343
423
  }
344
424
  JSONObject decision = new JSONObject(decisionJson);
345
425
  if (!"download".equals(decision.optString("action"))) {
346
- context.commitNativeCheckResult(
426
+ boolean committed = context.commitNativeCheckResult(
347
427
  resetGeneration, null, null, false,
348
428
  buildResponseCacheJson(configJson, body, responseText, responseAtSeconds));
429
+ roundResult = committed
430
+ ? NativeUpdateResult.of(NativeUpdateResult.NO_UPDATE, decision.optString("reason"))
431
+ : NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset");
349
432
  Log.i(UpdateContext.TAG,
350
433
  "native check: nothing to do (" + decision.optString("reason") + ")");
351
434
  return;
352
435
  }
353
436
  String hash = decision.optString("hash", "");
354
437
  if (!UpdateFileUtils.isSafePathComponent(hash)) {
438
+ roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_response");
355
439
  return;
356
440
  }
357
441
 
@@ -364,9 +448,12 @@ final class NativeCheckOrchestrator {
364
448
  if (!downloaded) {
365
449
  // The native attempt has finished, so JS may safely reuse the
366
450
  // response and retry through its own strategy chain.
367
- context.commitNativeCheckResult(
451
+ boolean committed = context.commitNativeCheckResult(
368
452
  resetGeneration, null, null, false,
369
453
  buildResponseCacheJson(configJson, body, responseText, responseAtSeconds));
454
+ roundResult = NativeUpdateResult.of(
455
+ committed ? NativeUpdateResult.FAILED : NativeUpdateResult.CANCELLED,
456
+ committed ? "download_failed" : "reset");
370
457
  return;
371
458
  }
372
459
 
@@ -412,6 +499,7 @@ final class NativeCheckOrchestrator {
412
499
  buildResponseCacheJson(configJson, body, responseText, responseAtSeconds));
413
500
  } catch (Exception e) {
414
501
  Log.w(UpdateContext.TAG, "native check: commit failed: " + e);
502
+ roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "commit_failed");
415
503
  return;
416
504
  }
417
505
  if (!committed) {
@@ -428,6 +516,9 @@ final class NativeCheckOrchestrator {
428
516
  Log.i(UpdateContext.TAG,
429
517
  "native check: downloaded " + hash + ", activation left to JS");
430
518
  }
519
+ roundResult = committed
520
+ ? NativeUpdateResult.downloaded(hash, activate)
521
+ : NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset");
431
522
  }
432
523
 
433
524
  private static String buildResponseCacheJson(
@@ -0,0 +1,99 @@
1
+ package cn.reactnative.modules.update;
2
+
3
+ import java.util.Arrays;
4
+ import java.util.HashSet;
5
+ import java.util.Iterator;
6
+ import java.util.Set;
7
+ import java.util.regex.Pattern;
8
+ import org.json.JSONArray;
9
+ import org.json.JSONException;
10
+ import org.json.JSONObject;
11
+
12
+ /** Internal normalizer for the JSONObject accepted by PushyNativeUpdate.configure. */
13
+ final class NativeUpdateConfig {
14
+ private static final Set<String> KEYS = new HashSet<>(Arrays.asList(
15
+ "appKey", "endpoints", "queryUrls", "afterDownload", "disabled",
16
+ "packageVersion", "rnu", "rn"));
17
+ private static final Pattern URL = Pattern.compile(
18
+ "^https?://(\\[[0-9a-fA-F:]+\\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)");
19
+ private static final String[] ENDPOINTS = {
20
+ "https://update.react-native.cn/api", "https://update.reactnative.cn/api"
21
+ };
22
+ private static final String[] QUERY_URLS = {
23
+ "https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json",
24
+ "https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json"
25
+ };
26
+
27
+ private NativeUpdateConfig() {}
28
+
29
+ static String normalize(String json) throws JSONException {
30
+ JSONObject options = new JSONObject(json);
31
+ for (Iterator<String> keys = options.keys(); keys.hasNext();) {
32
+ String key = keys.next();
33
+ if (!KEYS.contains(key)) {
34
+ throw invalid("unknown option " + key);
35
+ }
36
+ }
37
+ String appKey = string(options.opt("appKey"), "appKey", false);
38
+ boolean customEndpoints = options.has("endpoints");
39
+ JSONArray endpoints = urls(customEndpoints ? options.get("endpoints")
40
+ : new JSONArray(Arrays.asList(ENDPOINTS)), "endpoints", true);
41
+ JSONArray queryUrls = urls(options.has("queryUrls") ? options.get("queryUrls")
42
+ : new JSONArray(Arrays.asList(customEndpoints ? new String[0] : QUERY_URLS)),
43
+ "queryUrls", false);
44
+ String afterDownload = options.has("afterDownload")
45
+ ? string(options.get("afterDownload"), "afterDownload", false) : "none";
46
+ if (!"none".equals(afterDownload) && !"setNeedUpdate".equals(afterDownload)) {
47
+ throw invalid("afterDownload must be none or setNeedUpdate");
48
+ }
49
+ Object disabled = options.has("disabled") ? options.get("disabled") : Boolean.FALSE;
50
+ if (!(disabled instanceof Boolean)) {
51
+ throw invalid("disabled must be a boolean");
52
+ }
53
+ JSONObject result = new JSONObject();
54
+ result.put("appKey", appKey);
55
+ result.put("endpoints", endpoints);
56
+ result.put("queryUrls", queryUrls);
57
+ result.put("afterDownload", afterDownload);
58
+ result.put("disabled", disabled);
59
+ result.put("rnu", options.has("rnu") ? string(options.get("rnu"), "rnu", true) : "");
60
+ result.put("rn", options.has("rn") ? string(options.get("rn"), "rn", true) : "");
61
+ if (options.has("packageVersion")) {
62
+ result.put("packageVersion", string(options.get("packageVersion"), "packageVersion", false));
63
+ }
64
+ return result.toString();
65
+ }
66
+
67
+ private static String string(Object value, String name, boolean allowEmpty) {
68
+ if (!(value instanceof String) || (!allowEmpty && ((String) value).trim().isEmpty())) {
69
+ throw invalid(name + " must be a string" + (allowEmpty ? "" : " and must not be blank"));
70
+ }
71
+ return (String) value;
72
+ }
73
+
74
+ private static JSONArray urls(Object value, String name, boolean base) throws JSONException {
75
+ if (!(value instanceof JSONArray) || (base && ((JSONArray) value).length() == 0)) {
76
+ throw invalid(name + " must be " + (base ? "a non-empty" : "an") + " array");
77
+ }
78
+ JSONArray values = (JSONArray) value;
79
+ JSONArray result = new JSONArray();
80
+ Set<String> seen = new HashSet<>();
81
+ for (int i = 0; i < values.length(); i++) {
82
+ String url = string(values.get(i), name, false).trim();
83
+ if (!URL.matcher(url).find() || url.matches("(?s).*\\s.*") || url.contains("\\")
84
+ || (base && (url.contains("?") || url.contains("#")))) {
85
+ throw invalid(name + " requires absolute HTTP(S) URLs without credentials"
86
+ + (base ? ", queries or fragments" : ""));
87
+ }
88
+ String normalized = base ? url.replaceAll("/+$", "") : url;
89
+ if (seen.add(normalized)) {
90
+ result.put(normalized);
91
+ }
92
+ }
93
+ return result;
94
+ }
95
+
96
+ private static IllegalArgumentException invalid(String message) {
97
+ return new IllegalArgumentException("Invalid native configuration: " + message);
98
+ }
99
+ }
@@ -0,0 +1,51 @@
1
+ package cn.reactnative.modules.update;
2
+
3
+ /**
4
+ * Snapshot of a native check-and-update round. A downloaded result does not
5
+ * mean that the running React Native instance has been reloaded.
6
+ */
7
+ public final class NativeUpdateResult {
8
+ public static final String SKIPPED = "skipped";
9
+ public static final String NO_UPDATE = "noUpdate";
10
+ public static final String DOWNLOADED = "downloaded";
11
+ public static final String FAILED = "failed";
12
+ public static final String CANCELLED = "cancelled";
13
+
14
+ private final String status;
15
+ private final String reason;
16
+ private final String hash;
17
+ private final boolean activated;
18
+
19
+ private NativeUpdateResult(String status, String reason, String hash, boolean activated) {
20
+ this.status = status;
21
+ this.reason = reason;
22
+ this.hash = hash;
23
+ this.activated = activated;
24
+ }
25
+
26
+ static NativeUpdateResult of(String status, String reason) {
27
+ return new NativeUpdateResult(status, reason, "", false);
28
+ }
29
+
30
+ static NativeUpdateResult downloaded(String hash, boolean activated) {
31
+ return new NativeUpdateResult(DOWNLOADED, "", hash, activated);
32
+ }
33
+
34
+ public String getStatus() {
35
+ return status;
36
+ }
37
+
38
+ public String getReason() {
39
+ return reason;
40
+ }
41
+
42
+ /** Installed update hash, or an empty string when this round installed nothing. */
43
+ public String getHash() {
44
+ return hash;
45
+ }
46
+
47
+ /** Whether this round selected the downloaded version for the next launch. */
48
+ public boolean isActivated() {
49
+ return activated;
50
+ }
51
+ }
@@ -0,0 +1,127 @@
1
+ package cn.reactnative.modules.update;
2
+
3
+ import android.content.Context;
4
+ import android.os.Handler;
5
+ import android.os.Looper;
6
+ import android.util.Log;
7
+ import androidx.annotation.Nullable;
8
+ import org.json.JSONObject;
9
+ import java.util.concurrent.Executor;
10
+ import java.util.concurrent.Executors;
11
+ import java.util.concurrent.ThreadFactory;
12
+
13
+ /** Bridge-free native configuration and update APIs. */
14
+ public final class PushyNativeUpdate {
15
+ public interface Callback {
16
+ /** Always called on the main thread, including skipped and failed checks. */
17
+ void onComplete(NativeUpdateResult result);
18
+ }
19
+
20
+ // A single waiting worker, not one thread per caller. The actual round is
21
+ // shared with cold start and crash rescue by NativeCheckOrchestrator.
22
+ private static final Executor WORKER = Executors.newSingleThreadExecutor(new ThreadFactory() {
23
+ @Override
24
+ public Thread newThread(Runnable runnable) {
25
+ Thread thread = new Thread(runnable, "pushy-host-check");
26
+ thread.setDaemon(true);
27
+ return thread;
28
+ }
29
+ });
30
+
31
+ public interface ConfigurationCallback {
32
+ /** Main thread; null means configuration was persisted successfully. */
33
+ void onComplete(@Nullable Exception error);
34
+ }
35
+
36
+ // Configuration must not wait behind a network round that it invalidates.
37
+ private static final Executor CONFIG_WORKER = Executors.newSingleThreadExecutor(new ThreadFactory() {
38
+ @Override
39
+ public Thread newThread(Runnable runnable) {
40
+ Thread thread = new Thread(runnable, "pushy-host-config");
41
+ thread.setDaemon(true);
42
+ return thread;
43
+ }
44
+ });
45
+
46
+ /**
47
+ * Validate and persist a complete configuration, even before JS or bundle
48
+ * resolution. This starts no network work and never resolves a bundle.
49
+ * Await the callback before continuing startup/checkAndUpdate. Unless JS
50
+ * uses nativeConfigSource: 'native', later JS config writes can replace it.
51
+ */
52
+ public static void configure(Context context, JSONObject options, final ConfigurationCallback callback) {
53
+ if (context == null || options == null || callback == null) {
54
+ throw new IllegalArgumentException("context, options and callback are required");
55
+ }
56
+ final Context applicationContext = context.getApplicationContext();
57
+ // Snapshot caller-owned JSON before dispatch, not minutes later on a worker.
58
+ final String snapshot = options.toString();
59
+ CONFIG_WORKER.execute(new Runnable() {
60
+ @Override
61
+ public void run() {
62
+ Exception failure = null;
63
+ try {
64
+ String config = NativeUpdateConfig.normalize(snapshot);
65
+ UpdateContext.getInstance(applicationContext).setNativeConfig(config);
66
+ } catch (Exception e) {
67
+ failure = e;
68
+ } catch (LinkageError e) {
69
+ failure = new IllegalStateException("Native configuration failed", e);
70
+ }
71
+ final Exception error = failure;
72
+ new Handler(Looper.getMainLooper()).post(new Runnable() {
73
+ @Override
74
+ public void run() {
75
+ callback.onComplete(error);
76
+ }
77
+ });
78
+ }
79
+ });
80
+ }
81
+
82
+ private PushyNativeUpdate() {
83
+ }
84
+
85
+ /**
86
+ * Start the process's native check now, join its in-flight round, or return
87
+ * its completed result. This does not reload React Native or show UI.
88
+ *
89
+ * Call after the host has resolved its real launch bundle with
90
+ * UpdateContext.getBundleUrl. Do not resolve the bundle again just to call
91
+ * this method: bundle resolution consumes first-load/rollback markers.
92
+ * Missing persisted configuration and debug builds are reported as skipped.
93
+ */
94
+ public static void checkAndUpdate(Context context, final Callback callback) {
95
+ if (context == null || callback == null) {
96
+ throw new IllegalArgumentException("context and callback are required");
97
+ }
98
+ final Context applicationContext = context.getApplicationContext();
99
+ WORKER.execute(new Runnable() {
100
+ @Override
101
+ public void run() {
102
+ NativeUpdateResult outcome;
103
+ try {
104
+ if (BuildConfig.DEBUG) {
105
+ outcome = NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "debug");
106
+ } else {
107
+ outcome = NativeCheckOrchestrator.checkAndUpdate(
108
+ UpdateContext.getInstance(applicationContext));
109
+ }
110
+ } catch (InterruptedException e) {
111
+ Thread.currentThread().interrupt();
112
+ outcome = NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "interrupted");
113
+ } catch (Exception | LinkageError e) {
114
+ Log.w("react-native-update", "native host check failed", e);
115
+ outcome = NativeUpdateResult.of(NativeUpdateResult.FAILED, "internal_error");
116
+ }
117
+ final NativeUpdateResult result = outcome;
118
+ new Handler(Looper.getMainLooper()).post(new Runnable() {
119
+ @Override
120
+ public void run() {
121
+ callback.onComplete(result);
122
+ }
123
+ });
124
+ }
125
+ });
126
+ }
127
+ }
@@ -737,6 +737,41 @@ public class UpdateContext {
737
737
  }
738
738
 
739
739
  /** Sampled/compared by the native check orchestrator; see resetGeneration. */
740
+ /** Shared by JS and native hosts. Config replacement invalidates old native decisions. */
741
+ private static final java.util.concurrent.atomic.AtomicLong nativeConfigGeneration =
742
+ new java.util.concurrent.atomic.AtomicLong(0);
743
+
744
+ static long getNativeConfigGeneration() {
745
+ return nativeConfigGeneration.get();
746
+ }
747
+
748
+ void setNativeConfig(String config) {
749
+ synchronized (commitLock) {
750
+ boolean changed = !config.equals(sp.getString(NativeCheckOrchestrator.KEY_CONFIG, null));
751
+ SharedPreferences.Editor editor = sp.edit();
752
+ if (changed) {
753
+ // Also invalidate on a failed persistence attempt: an older
754
+ // round must not commit over uncertain configuration state.
755
+ resetGeneration.incrementAndGet();
756
+ nativeConfigGeneration.incrementAndGet();
757
+ editor.remove(NativeCheckOrchestrator.KEY_RESP_CACHE);
758
+ NativeCheckOrchestrator.markJsCheckCompleted(null);
759
+ }
760
+ // A native-only first launch needs a stable gray-release identity
761
+ // before JS initializes. Never replace an existing installation ID.
762
+ String uuid = sp.getString("uuid", null);
763
+ if (uuid == null || uuid.isEmpty()) {
764
+ editor.putString("uuid", java.util.UUID.randomUUID().toString());
765
+ }
766
+ editor.putString(NativeCheckOrchestrator.KEY_CONFIG, config);
767
+ // Persist even an equal value: a previous commit may have updated
768
+ // SharedPreferences memory but failed to write its file.
769
+ persistEditorOrThrow(editor, "configure native update");
770
+ }
771
+ NativeCheckOrchestrator.onConfigured(this);
772
+ }
773
+
774
+ // Native-decision generation: bumped by reset AND configuration replacement.
740
775
  static long getResetGeneration() {
741
776
  return resetGeneration.get();
742
777
  }
@@ -339,7 +339,7 @@ public class UpdateModuleImpl {
339
339
  StateSerialRunner.run(promise, ErrorCodes.FILE_OPERATION_FAILED, "syncNativeConfig", new StateSerialRunner.Operation() {
340
340
  @Override
341
341
  public void run() {
342
- updateContext.setKv(NativeCheckOrchestrator.KEY_CONFIG, config);
342
+ updateContext.setNativeConfig(config);
343
343
  promise.resolve(true);
344
344
  }
345
345
  });