react-native-update 10.59.1 → 10.60.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.
Files changed (125) hide show
  1. package/README-CN.md +7 -7
  2. package/README.md +7 -6
  3. package/android/build.gradle +25 -33
  4. package/android/jni/Android.mk +16 -24
  5. package/android/lib/arm64-v8a/librnpushy.so +0 -0
  6. package/android/lib/armeabi-v7a/librnpushy.so +0 -0
  7. package/android/lib/x86/librnpushy.so +0 -0
  8. package/android/lib/x86_64/librnpushy.so +0 -0
  9. package/android/proguard.pro +9 -7
  10. package/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java +11 -12
  11. package/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java +23 -6
  12. package/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +50 -51
  13. package/android/src/main/java/cn/reactnative/modules/update/CleanupKeepNames.java +12 -13
  14. package/android/src/main/java/cn/reactnative/modules/update/CopyGroupResult.java +3 -0
  15. package/android/src/main/java/cn/reactnative/modules/update/CrashHold.java +22 -30
  16. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +181 -332
  17. package/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +3 -1
  18. package/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java +27 -0
  19. package/android/src/main/java/cn/reactnative/modules/update/FileOperationException.java +1 -1
  20. package/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java +32 -8
  21. package/android/src/main/java/cn/reactnative/modules/update/HttpClients.java +63 -0
  22. package/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java +0 -11
  23. package/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +66 -116
  24. package/android/src/main/java/cn/reactnative/modules/update/NativeCore.java +9 -1
  25. package/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +1 -1
  26. package/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java +32 -60
  27. package/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java +8 -23
  28. package/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java +42 -80
  29. package/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java +22 -39
  30. package/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +176 -299
  31. package/android/src/main/java/cn/reactnative/modules/update/Threads.java +35 -0
  32. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +242 -174
  33. package/android/src/main/java/cn/reactnative/modules/update/UpdateEventEmitter.java +1 -1
  34. package/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java +75 -17
  35. package/android/src/main/java/cn/reactnative/modules/update/{UpdateModuleImpl.java → UpdateModule.java} +113 -95
  36. package/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java +16 -19
  37. package/android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java +69 -0
  38. package/cpp/android_jni/archive_limits_jni.cpp +83 -0
  39. package/cpp/android_jni/install_record_jni.cpp +89 -0
  40. package/cpp/android_jni/jni_log.h +31 -0
  41. package/cpp/android_jni/jni_natives.h +40 -0
  42. package/cpp/android_jni/jni_registration.cpp +133 -0
  43. package/cpp/android_jni/jni_util.h +160 -0
  44. package/cpp/android_jni/update_core_android.cpp +450 -0
  45. package/cpp/android_jni/update_flow_jni.cpp +133 -0
  46. package/{android/jni → cpp/hpatch}/hpatch.c +5 -5
  47. package/{android/jni → cpp/hpatch}/hpatch.h +4 -1
  48. package/cpp/patch_core/archive_limits.cpp +194 -0
  49. package/cpp/patch_core/archive_limits.h +99 -10
  50. package/cpp/patch_core/archive_patch_core.cpp +96 -12
  51. package/cpp/patch_core/archive_patch_core.h +49 -0
  52. package/cpp/patch_core/digest.h +5 -4
  53. package/cpp/patch_core/error_codes.h +5 -3
  54. package/cpp/patch_core/install_record.cpp +430 -0
  55. package/cpp/patch_core/install_record.h +163 -9
  56. package/cpp/patch_core/patch_core.cpp +62 -16
  57. package/cpp/patch_core/patch_core.h +27 -7
  58. package/cpp/patch_core/patch_manifest.cpp +201 -0
  59. package/cpp/patch_core/patch_manifest.h +74 -0
  60. package/cpp/patch_core/state_ops.h +3 -3
  61. package/cpp/update_flow_core/flow_json.cpp +82 -8
  62. package/cpp/update_flow_core/flow_json.h +21 -0
  63. package/cpp/update_flow_core/native_config.cpp +456 -0
  64. package/cpp/update_flow_core/native_config.h +84 -0
  65. package/cpp/update_flow_core/native_round.cpp +326 -0
  66. package/cpp/update_flow_core/native_round.h +90 -0
  67. package/cpp/update_flow_core/update_flow_core.h +2 -2
  68. package/harmony/hvigor-plugin.ts +11 -19
  69. package/harmony/pushy/src/main/cpp/CMakeLists.txt +19 -26
  70. package/harmony/pushy/src/main/cpp/PushyPackage.h +2 -2
  71. package/harmony/pushy/src/main/cpp/PushyTurboModule.cpp +55 -94
  72. package/harmony/pushy/src/main/cpp/pushy.cpp +694 -517
  73. package/harmony/pushy/src/main/ets/ArchiveLimits.ts +48 -72
  74. package/harmony/pushy/src/main/ets/DownloadTask.ts +164 -404
  75. package/harmony/pushy/src/main/ets/DownloadTaskParams.ts +1 -3
  76. package/harmony/pushy/src/main/ets/ErrorCodes.ts +1 -14
  77. package/harmony/pushy/src/main/ets/EventHub.ts +1 -1
  78. package/harmony/pushy/src/main/ets/InstallRecord.ts +52 -151
  79. package/harmony/pushy/src/main/ets/Logger.ts +20 -34
  80. package/harmony/pushy/src/main/ets/NativePatchCore.ts +116 -42
  81. package/harmony/pushy/src/main/ets/PushyConfiguration.ts +8 -85
  82. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +11 -25
  83. package/harmony/pushy/src/main/ets/SaveFile.ts +99 -18
  84. package/harmony/pushy/src/main/ets/SyncCoordinator.ts +136 -335
  85. package/harmony/pushy/src/main/ets/Texts.ts +0 -4
  86. package/harmony/pushy/src/main/ets/UpdateContext.ts +106 -119
  87. package/harmony/pushy.har +0 -0
  88. package/ios/RCTPushy/RCTPushy+Launch.mm +185 -0
  89. package/ios/RCTPushy/RCTPushy.h +12 -0
  90. package/ios/RCTPushy/RCTPushy.mm +87 -2628
  91. package/ios/RCTPushy/RCTPushyCommon.h +34 -0
  92. package/ios/RCTPushy/RCTPushyConfiguration.h +0 -8
  93. package/ios/RCTPushy/RCTPushyConfiguration.mm +40 -121
  94. package/ios/RCTPushy/RCTPushyDownloader.mm +40 -37
  95. package/ios/RCTPushy/RCTPushyInstallRecord.mm +61 -0
  96. package/ios/RCTPushy/RCTPushyInstaller.mm +689 -0
  97. package/ios/RCTPushy/RCTPushyInternal.h +144 -0
  98. package/ios/RCTPushy/RCTPushyOrchestrator.mm +1001 -0
  99. package/ios/RCTPushy/RCTPushyState.mm +314 -0
  100. package/package.json +25 -23
  101. package/react-native-update.podspec +74 -83
  102. package/src/client.ts +361 -451
  103. package/src/context.ts +1 -1
  104. package/src/core.ts +35 -8
  105. package/src/endpoint.ts +0 -2
  106. package/src/error.ts +13 -32
  107. package/src/i18n.ts +18 -83
  108. package/src/locales/en.ts +0 -4
  109. package/src/locales/zh.ts +0 -4
  110. package/src/provider.tsx +60 -53
  111. package/src/telemetry.ts +0 -13
  112. package/src/type.ts +0 -15
  113. package/src/updateFlowCore.ts +116 -10
  114. package/src/utils.ts +49 -42
  115. package/android/src/main/java/cn/reactnative/modules/update/ArchivePatchPlanResult.java +0 -6
  116. package/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java +0 -104
  117. package/android/src/main/java/cn/reactnative/modules/update/UiThreadRunner.java +0 -37
  118. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +0 -121
  119. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +0 -125
  120. package/cpp/patch_core/jni_natives.h +0 -27
  121. package/cpp/patch_core/jni_registration.cpp +0 -103
  122. package/cpp/patch_core/jni_util.h +0 -60
  123. package/cpp/patch_core/patch_core_android.cpp +0 -97
  124. package/cpp/patch_core/update_core_android.cpp +0 -485
  125. package/cpp/update_flow_core/update_flow_jni.cpp +0 -150
@@ -6,10 +6,10 @@ import androidx.annotation.Nullable;
6
6
  import java.io.IOException;
7
7
  import java.nio.charset.Charset;
8
8
  import java.nio.charset.StandardCharsets;
9
- import java.util.HashSet;
10
9
  import java.util.concurrent.CountDownLatch;
11
10
  import java.util.concurrent.TimeUnit;
12
11
  import java.util.concurrent.atomic.AtomicBoolean;
12
+ import java.util.concurrent.atomic.AtomicReference;
13
13
  import okhttp3.MediaType;
14
14
  import okhttp3.OkHttpClient;
15
15
  import okhttp3.Request;
@@ -31,19 +31,18 @@ import org.json.JSONObject;
31
31
  * version blacklisting.
32
32
  */
33
33
  final class SyncCoordinator {
34
- // Version-info flags read by JS (metadata.ts), stored encoded.
34
+ // Version-info flag read by JS (metadata.ts), stored encoded.
35
35
  private static final String INFO_CRASH_HOLD = Texts.reveal("3905f5c2a6b96d56212a19");
36
- private static final String INFO_FORCE_BOOT = Texts.reveal("3c18e6d2aba9674a360d19ead5a695");
37
36
 
38
37
  static final String KEY_CONFIG = "nativeConfig";
39
38
  // Raw response cache for the JS side to reuse (§10.3), scoped to the
40
- // exact logical request and native config that produced it.
39
+ // exact logical request and native config that produced it. Stored in
40
+ // its own SharedPreferences file (UpdateContext.getNativeCheckCache).
41
41
  static final String KEY_RESP_CACHE = "nativeCheckResp";
42
42
  // Set when a round starts, cleared when it ends (§11.4). Residue on the
43
43
  // next launch means the previous process died mid-round (a crash rescue
44
44
  // was truncated): that launch resumes immediately instead of waiting 5s.
45
45
  static final String KEY_ROUND_INCOMPLETE = "nativeCheckIncomplete";
46
- private static final int MAX_QUERY_HTTP_ATTEMPTS = 8;
47
46
  private static final long DOWNLOAD_PHASE_TIMEOUT_SECONDS = 600;
48
47
  // The check response (and a remote queryUrls list) is a small JSON
49
48
  // document; anything bigger is a broken or hijacked endpoint.
@@ -85,26 +84,16 @@ final class SyncCoordinator {
85
84
 
86
85
  /** Blocking only on the host API's worker; never call on the UI thread. */
87
86
  static BundlePreparationResult prepareBundle(UpdateContext context) throws InterruptedException {
88
- if (UpdateContext.DEBUG) {
87
+ if (BuildConfig.DEBUG) {
89
88
  return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "debug");
90
89
  }
91
90
  if (!nativeReady || sContext != context || !context.getIsUsingBundleUrl()) {
92
91
  return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "not_initialized");
93
92
  }
94
93
  String configJson = context.getKv(KEY_CONFIG);
95
- if (configJson == null || configJson.isEmpty()) {
96
- return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "not_configured");
97
- }
98
- try {
99
- JSONObject config = new JSONObject(configJson);
100
- if (config.optBoolean("disabled", false)) {
101
- return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "disabled");
102
- }
103
- if (config.optString("appKey", "").isEmpty()) {
104
- return BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_config");
105
- }
106
- } catch (JSONException e) {
107
- return BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_config");
94
+ BundlePreparationResult rejection = configRejection(configJson);
95
+ if (rejection != null) {
96
+ return rejection;
108
97
  }
109
98
  startRound(0);
110
99
  if (!roundStarted.get()) {
@@ -112,7 +101,8 @@ final class SyncCoordinator {
112
101
  }
113
102
  roundDone.await();
114
103
  if (roundConfigGeneration != UpdateContext.getNativeConfigGeneration()
115
- || !configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) {
104
+ || !FlowBridge.configsEquivalent(configJson, roundConfigJson)
105
+ || !FlowBridge.configsEquivalent(configJson, context.getKv(KEY_CONFIG))) {
116
106
  return BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "config_changed");
117
107
  }
118
108
  if (roundGeneration != UpdateContext.getResetGeneration()) {
@@ -127,21 +117,49 @@ final class SyncCoordinator {
127
117
  }
128
118
 
129
119
  /**
130
- * True when JS already obtained a valid response in this process for the
131
- * exact config the native round would use: the delayed round is then a
132
- * duplicate request. Only the scheduled round consults this — the
133
- * crash-rescue path still runs, JS is dead by then.
120
+ * True when JS already obtained a valid response in this process for a
121
+ * config equivalent (FlowBridge.configsEquivalent, contract C1) to
122
+ * the one the native round would use: the delayed round is then a
123
+ * duplicate request. JS and a host may serialize the same config
124
+ * differently, so exact string equality would almost never match. Only
125
+ * the scheduled round consults this — the crash-rescue path still runs,
126
+ * JS is dead by then.
134
127
  */
135
128
  private static boolean hasJsRound(UpdateContext context) {
136
129
  String jsConfig = sJsCompletedConfig;
137
- return jsConfig != null && jsConfig.equals(context.getKv(KEY_CONFIG));
130
+ return jsConfig != null
131
+ && FlowBridge.configsEquivalent(jsConfig, context.getKv(KEY_CONFIG));
132
+ }
133
+
134
+ /**
135
+ * The shared gate (FlowBridge.runnableConfig, update_flow_core) every
136
+ * entry point (prepareBundle, the automatic round's gate, the round
137
+ * itself) applies to the persisted config, so they cannot disagree on
138
+ * what is runnable: null when a round may run, otherwise the result —
139
+ * absent is skipped/not_configured, a non-object failed/invalid_config, a
140
+ * truthy disabled skipped/disabled, a blank appKey failed/invalid_config.
141
+ */
142
+ @Nullable
143
+ static BundlePreparationResult configRejection(@Nullable String json) {
144
+ try {
145
+ String result = FlowBridge.runnableConfig(json);
146
+ if (result == null) {
147
+ return BundlePreparationResult.of(BundlePreparationResult.FAILED, "internal_error");
148
+ }
149
+ JSONObject verdict = new JSONObject(result);
150
+ return verdict.has("status")
151
+ ? BundlePreparationResult.of(verdict.getString("status"), verdict.getString("reason"))
152
+ : null;
153
+ } catch (JSONException e) {
154
+ return BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_config");
155
+ }
138
156
  }
139
157
 
140
158
  private SyncCoordinator() {
141
159
  }
142
160
 
143
161
  static void schedule(final UpdateContext context, final String launchRolledBackVersion) {
144
- if (UpdateContext.DEBUG) {
162
+ if (BuildConfig.DEBUG) {
145
163
  return;
146
164
  }
147
165
  if (!scheduled.compareAndSet(false, true)) {
@@ -155,60 +173,35 @@ final class SyncCoordinator {
155
173
  if (context.getKv(KEY_CONFIG) != null) {
156
174
  CrashHold.install();
157
175
  }
158
- Thread thread = new Thread(new Runnable() {
159
- @Override
160
- public void run() {
161
- try {
162
- // Keep the check away from the cold-start critical path
163
- // (§7 R5) — unless the previous process died mid-round,
164
- // in which case every launch second counts (§11.4).
165
- if (context.getKv(KEY_ROUND_INCOMPLETE) == null) {
166
- Thread.sleep(5000);
167
- }
168
- if (hasJsRound(context)) {
169
- // Not consuming the round: a later crash rescue may
170
- // still need it.
171
- Log.i(Texts.LOG_TAG,
172
- "native sync skipped: JS already queried in this process");
173
- return;
174
- }
175
- startRound(0);
176
- } catch (Throwable e) {
177
- // The rescue path must never take the app down with it.
178
- Log.w(Texts.LOG_TAG, "native sync failed: " + e);
176
+ Threads.startDaemon("pushy-native-sync", Thread.MIN_PRIORITY + 1, () -> {
177
+ try {
178
+ // Keep the check away from the cold-start critical path
179
+ // (§7 R5) — unless the previous process died mid-round,
180
+ // in which case every launch second counts (§11.4).
181
+ if (context.getKv(KEY_ROUND_INCOMPLETE) == null) {
182
+ Thread.sleep(5000);
183
+ }
184
+ if (hasJsRound(context)) {
185
+ // Not consuming the round: a later crash rescue may
186
+ // still need it.
187
+ Log.i(Texts.LOG_TAG,
188
+ "native sync skipped: JS already queried in this process");
189
+ return;
179
190
  }
191
+ startRound(0);
192
+ } catch (Throwable e) {
193
+ // The rescue path must never take the app down with it.
194
+ Log.w(Texts.LOG_TAG, "native sync failed: " + e);
180
195
  }
181
- }, "pushy-native-sync");
182
- thread.setPriority(Thread.MIN_PRIORITY + 1);
183
- thread.setDaemon(true);
184
- thread.start();
196
+ });
185
197
  }
186
198
 
187
199
  static boolean isRoundInFlight() {
188
200
  return roundStarted.get() && !roundCompleted;
189
201
  }
190
202
 
191
- /**
192
- * Runs the process's single round on the calling thread if nobody has
193
- * started it yet. deadlineNanos > 0 (crash rescue) caps every HTTP call
194
- * and download phase to the remaining budget.
195
- */
196
203
  private static boolean hasRunnableConfig(UpdateContext context) {
197
- if (context == null) {
198
- return false;
199
- }
200
- try {
201
- String json = context.getKv(KEY_CONFIG);
202
- if (json == null) {
203
- return false;
204
- }
205
- JSONObject config = new JSONObject(json);
206
- return !config.optBoolean("disabled", false)
207
- && config.opt("appKey") instanceof String
208
- && !config.getString("appKey").trim().isEmpty();
209
- } catch (JSONException e) {
210
- return false;
211
- }
204
+ return context != null && configRejection(context.getKv(KEY_CONFIG)) == null;
212
205
  }
213
206
 
214
207
  static void onConfigured(UpdateContext context) {
@@ -217,6 +210,11 @@ final class SyncCoordinator {
217
210
  }
218
211
  }
219
212
 
213
+ /**
214
+ * Runs the process's single round on the calling thread if nobody has
215
+ * started it yet. deadlineNanos > 0 (crash rescue) caps every HTTP call
216
+ * and download phase to the remaining budget.
217
+ */
220
218
  private static void startRound(long deadlineNanos) {
221
219
  // An automatic check before first-run provisioning must not consume
222
220
  // the process's only round. Hosts may configure later in this launch.
@@ -310,24 +308,9 @@ final class SyncCoordinator {
310
308
  roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "check_failed");
311
309
  String configJson = context.getKv(KEY_CONFIG);
312
310
  roundConfigJson = configJson;
313
- if (configJson == null || configJson.isEmpty()) {
314
- roundResult = BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "not_configured");
315
- return;
316
- }
317
- JSONObject config;
318
- try {
319
- config = new JSONObject(configJson);
320
- } catch (JSONException e) {
321
- roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_config");
322
- return;
323
- }
324
- if (config.optBoolean("disabled", false)) {
325
- roundResult = BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "disabled");
326
- return;
327
- }
328
- String appKey = config.optString("appKey", "");
329
- if (appKey.isEmpty()) {
330
- roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_config");
311
+ BundlePreparationResult rejection = configRejection(configJson);
312
+ if (rejection != null) {
313
+ roundResult = rejection;
331
314
  return;
332
315
  }
333
316
  // Keep the existing interrupted-round breadcrumb and reset generation.
@@ -337,11 +320,15 @@ final class SyncCoordinator {
337
320
  }
338
321
  try {
339
322
  runConfiguredRound(
340
- context, launchRolledBackVersion, deadlineNanos,
341
- resetGeneration, configJson, config, appKey);
323
+ context, launchRolledBackVersion, deadlineNanos, resetGeneration, configJson);
342
324
  } finally {
325
+ // A committed round already cleared the breadcrumb in its own
326
+ // commit (UpdateContext.commitSyncResult); only rounds that ended
327
+ // without committing need the extra write.
343
328
  try {
344
- context.removeKv(KEY_ROUND_INCOMPLETE);
329
+ if (context.getKv(KEY_ROUND_INCOMPLETE) != null) {
330
+ context.removeKv(KEY_ROUND_INCOMPLETE);
331
+ }
345
332
  } catch (IllegalStateException ignored) {
346
333
  }
347
334
  }
@@ -352,64 +339,37 @@ final class SyncCoordinator {
352
339
  String launchRolledBackVersion,
353
340
  long deadlineNanos,
354
341
  long resetGeneration,
355
- String configJson,
356
- JSONObject config,
357
- String appKey
342
+ String configJson
358
343
  ) throws JSONException {
359
- String packageVersion = config.optString(
360
- "packageVersion", context.getPackageVersion());
361
- if (packageVersion.isEmpty()) {
362
- packageVersion = context.getPackageVersion();
363
- }
364
-
365
344
  String currentVersion = context.getCurrentVersion();
345
+ // This launch as the shared core sees it (native_round.h). Absent
346
+ // values (null) are left out.
347
+ JSONObject env = new JSONObject();
348
+ env.put("packageVersion", context.getPackageVersion());
349
+ // The JS constant's form, so JS can reuse the response cache.
350
+ env.put("currentVersion", currentVersion == null ? JSONObject.NULL : currentVersion);
351
+ env.put("uuid", context.getKv("uuid"));
366
352
  // Snapshot captured on the launch path before getConstants consumes
367
353
  // the one-shot rollback marker. Reading SharedPreferences here, five
368
354
  // seconds later, would lose the guard and could forceBoot the version
369
355
  // that this very launch just rolled back.
370
- String rolledBackVersion = launchRolledBackVersion;
371
- String uuid = context.getKv("uuid");
372
- if (uuid == null) {
373
- uuid = "";
374
- }
375
-
376
- JSONObject identity = new JSONObject();
377
- identity.put("packageVersion", packageVersion);
378
- identity.put(
379
- "currentVersion",
380
- currentVersion == null ? JSONObject.NULL : currentVersion
381
- );
382
- identity.put("uuid", uuid);
383
- if (rolledBackVersion != null) {
384
- identity.put("rolledBackVersion", rolledBackVersion);
385
- }
386
-
387
- JSONObject cInfo = new JSONObject();
388
- cInfo.put("rnu", config.optString("rnu", ""));
389
- cInfo.put("rn", config.optString("rn", ""));
356
+ env.put("rolledBackVersion", launchRolledBackVersion);
357
+ env.put("buildTime", context.getBuildTime());
390
358
  // React Native's Platform.Version is the Android SDK integer; use the
391
359
  // same value so this request can be fingerprinted against the JS one.
392
- cInfo.put("os", "android " + Build.VERSION.SDK_INT);
393
- cInfo.put("uuid", uuid);
360
+ env.put("os", "android " + Build.VERSION.SDK_INT);
361
+ env.put("bundleHash", context.computeBundleHash());
362
+ String envJson = env.toString();
394
363
 
395
- JSONObject input = new JSONObject();
396
- input.put("packageVersion", packageVersion);
397
- input.put(
398
- "currentVersion",
399
- currentVersion == null ? JSONObject.NULL : currentVersion
400
- );
401
- input.put("buildTime", context.getBuildTime());
402
- input.put("cInfo", cInfo);
403
- input.put("supportedDiffVersion", NativeCore.supportedDiffVersion());
404
- input.put("bundleHash", context.computeBundleHash());
405
-
406
- String body = FlowBridge.buildRequestBody(input.toString());
407
- if (body == null) {
364
+ String preparedJson = FlowBridge.prepareRound(configJson, envJson, Math.random());
365
+ if (preparedJson == null) {
408
366
  roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_request");
409
367
  return;
410
368
  }
411
-
412
- String responseText = runQueryRequest(config, appKey, body, deadlineNanos);
369
+ JSONObject prepared = new JSONObject(preparedJson);
370
+ String body = prepared.getString("body");
371
+ String responseText =
372
+ runQueryRequest(prepared.getJSONObject("query").toString(), body, deadlineNanos);
413
373
  if (responseText == null) {
414
374
  Log.i(Texts.LOG_TAG,
415
375
  "native sync: no endpoint reachable, giving up until next launch");
@@ -417,19 +377,18 @@ final class SyncCoordinator {
417
377
  }
418
378
  // Cache freshness is anchored to when the server response arrived,
419
379
  // not to when a potentially long download/patch/activation finished.
420
- final long responseAtSeconds = System.currentTimeMillis() / 1000;
421
-
422
380
  String decisionJson = FlowBridge.handleResponse(
423
- responseText, identity.toString(), config.optString("afterDownload", ""));
381
+ responseText, configJson, envJson, body, System.currentTimeMillis() / 1000);
424
382
  if (decisionJson == null) {
425
383
  roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_response");
426
384
  return;
427
385
  }
428
386
  JSONObject decision = new JSONObject(decisionJson);
387
+ // Built once; every commit below caches it for JS.
388
+ final String responseCacheJson = decision.getString("cache");
429
389
  if (!"download".equals(decision.optString("action"))) {
430
390
  boolean committed = context.commitSyncResult(
431
- resetGeneration, null, null, false,
432
- buildResponseCacheJson(configJson, body, responseText, responseAtSeconds));
391
+ resetGeneration, null, null, false, responseCacheJson);
433
392
  roundResult = committed
434
393
  ? BundlePreparationResult.of(BundlePreparationResult.NO_UPDATE, decision.optString("reason"))
435
394
  : BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "reset");
@@ -446,47 +405,29 @@ final class SyncCoordinator {
446
405
  boolean downloaded = context.hasCompletedVersion(hash);
447
406
  if (!downloaded) {
448
407
  downloaded = performAttempts(
449
- context, decision.optJSONArray("attempts"), hash, currentVersion,
450
- deadlineNanos);
408
+ decision.optJSONArray("attempts"), deadlineNanos,
409
+ downloadStarter(context, hash, currentVersion));
451
410
  }
452
411
  if (!downloaded) {
453
412
  // The native attempt has finished, so JS may safely reuse the
454
413
  // response and retry through its own strategy chain.
455
414
  boolean committed = context.commitSyncResult(
456
- resetGeneration, null, null, false,
457
- buildResponseCacheJson(configJson, body, responseText, responseAtSeconds));
415
+ resetGeneration, null, null, false, responseCacheJson);
458
416
  roundResult = BundlePreparationResult.of(
459
417
  committed ? BundlePreparationResult.FAILED : BundlePreparationResult.CANCELLED,
460
418
  committed ? "download_failed" : "reset");
461
419
  return;
462
420
  }
463
421
 
464
- // Version info (mirroring the JS side's setLocalHashInfo), the
465
- // activation and the response cache all land in one atomic commit —
466
- // see UpdateContext.commitSyncResult.
467
- String hashInfoJson = null;
468
- JSONObject info = decision.optJSONObject("info");
469
- if (info != null) {
470
- JSONObject hashInfo = new JSONObject();
471
- for (String key : new String[] {"name", "description", "metaInfo"}) {
472
- Object value = info.opt(key);
473
- if (value instanceof String) {
474
- hashInfo.put(key, value);
475
- }
476
- }
477
- // A forceBoot activation is the brick-rescue path: mark it in the
478
- // persisted info so JS can report force_boot_rescue when this
479
- // version survives to markSuccess. Only the server-sent directive
480
- // counts — a silent-strategy activation is ordinary delivery.
481
- JSONObject infoConfig = info.optJSONObject("config");
482
- if (infoConfig != null && infoConfig.optBoolean("forceBoot", false)) {
483
- hashInfo.put(INFO_FORCE_BOOT, true);
484
- }
485
- if (crashHoldActive) {
486
- hashInfo.put(INFO_CRASH_HOLD, true);
487
- }
488
- hashInfoJson = hashInfo.toString();
422
+ // Version info (mirroring the JS side's setLocalHashInfo, the
423
+ // forceBoot rescue marked by the core), the activation and the
424
+ // response cache all land in one atomic commit — see
425
+ // UpdateContext.commitSyncResult.
426
+ JSONObject hashInfo = decision.getJSONObject("hashInfo");
427
+ if (crashHoldActive) {
428
+ hashInfo.put(INFO_CRASH_HOLD, true);
489
429
  }
430
+ String hashInfoJson = hashInfo.toString();
490
431
  // Silent strategies or a server-marked forceBoot version (per-version
491
432
  // remote override — the brick rescue) activate for the next launch;
492
433
  // otherwise activation stays with the JS side. Unless a crash is
@@ -500,7 +441,7 @@ final class SyncCoordinator {
500
441
  hash,
501
442
  hashInfoJson,
502
443
  activate,
503
- buildResponseCacheJson(configJson, body, responseText, responseAtSeconds));
444
+ responseCacheJson);
504
445
  } catch (Exception e) {
505
446
  Log.w(Texts.LOG_TAG, "native sync: commit failed: " + e);
506
447
  roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "commit_failed");
@@ -525,21 +466,7 @@ final class SyncCoordinator {
525
466
  : BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "reset");
526
467
  }
527
468
 
528
- private static String buildResponseCacheJson(
529
- String configJson,
530
- String requestBody,
531
- String responseText,
532
- long responseAtSeconds
533
- ) throws JSONException {
534
- JSONObject cacheEntry = new JSONObject();
535
- cacheEntry.put("ts", responseAtSeconds);
536
- cacheEntry.put("body", responseText);
537
- cacheEntry.put("request", requestBody);
538
- cacheEntry.put("config", configJson);
539
- return cacheEntry.toString();
540
- }
541
-
542
- private static final OkHttpClient httpClient = new OkHttpClient.Builder()
469
+ private static final OkHttpClient httpClient = HttpClients.base().newBuilder()
543
470
  .connectTimeout(10, TimeUnit.SECONDS)
544
471
  .readTimeout(10, TimeUnit.SECONDS)
545
472
  .callTimeout(15, TimeUnit.SECONDS)
@@ -553,25 +480,13 @@ final class SyncCoordinator {
553
480
  builder.post(RequestBody.create(
554
481
  postBody, MediaType.parse("application/json; charset=utf-8")));
555
482
  }
556
- OkHttpClient client = httpClient;
557
- if (deadlineNanos > 0) {
558
- // Crash-rescue budget: never let a single request outlive the
559
- // handler's hold window.
560
- long remainingMillis = TimeUnit.NANOSECONDS.toMillis(
561
- deadlineNanos - System.nanoTime());
562
- if (remainingMillis <= 0) {
563
- return null;
564
- }
565
- if (remainingMillis < 15000) {
566
- client = httpClient.newBuilder()
567
- .callTimeout(remainingMillis, TimeUnit.MILLISECONDS)
568
- .build();
569
- }
570
- }
483
+ // Crash-rescue budget: never let a single request outlive the
484
+ // handler's hold window.
485
+ OkHttpClient client = HttpClients.withDeadline(httpClient, deadlineNanos);
571
486
  try (Response response = client.newCall(builder.build()).execute()) {
572
487
  // Same rule as the artifact download: an https endpoint that
573
488
  // redirects to plaintext http is a failed endpoint.
574
- DownloadTask.rejectProtocolDowngrade(url, response);
489
+ HttpClients.rejectProtocolDowngrade(url, response);
575
490
  ResponseBody body = response.body();
576
491
  if (!response.isSuccessful() || body == null) {
577
492
  return null;
@@ -609,89 +524,32 @@ final class SyncCoordinator {
609
524
  return source.readString(charset == null ? StandardCharsets.UTF_8 : charset);
610
525
  }
611
526
 
612
- // Shared schema rule (update_flow_core::IsValidResponse): a 200 with
613
- // `{"error": ...}` is a failed endpoint, not a verdict, and must not stop
614
- // the endpoint fallback.
615
- private static boolean isValidResponse(String responseText) {
616
- return responseText != null && FlowBridge.isValidResponse(responseText);
617
- }
618
-
619
527
  /**
620
- * Sequential fallback over the ordered candidates (§5.1): one request at
621
- * a time with its own timeout; after the configured round fails,
622
- * queryUrls discovery merges remote candidates (excluding the
623
- * already-tried) for one more round. No hedged race on purpose — this
528
+ * Sequential fallback over the endpoints (§5.1), one request at a time
529
+ * with its own timeout, as the shared core walks it
530
+ * (FlowBridge.nextQueryStep: order, list discovery, https-only remote
531
+ * bases, budget, response validity). No hedged race on purpose — this
624
532
  * path is latency-insensitive.
625
533
  */
626
- private static String runQueryRequest(
627
- JSONObject config, String appKey, String body, long deadlineNanos
628
- ) {
629
- JSONArray endpoints = config.optJSONArray("endpoints");
630
- String orderedJson = FlowBridge.orderEndpointCandidates(
631
- endpoints == null ? "[]" : endpoints.toString(), Math.random());
632
- JSONArray ordered;
633
- try {
634
- ordered = orderedJson == null ? new JSONArray() : new JSONArray(orderedJson);
635
- } catch (JSONException e) {
636
- return null;
637
- }
638
- HashSet<String> tried = new HashSet<>();
639
- int httpAttempts = 0;
640
- for (int i = 0; i < ordered.length(); i++) {
641
- String base = HttpUtils.normalizeEndpointBase(ordered.optString(i, ""));
642
- if (base.isEmpty() || !tried.add(base)) {
643
- continue;
644
- }
645
- if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) {
534
+ @Nullable
535
+ private static String runQueryRequest(String state, String body, long deadlineNanos)
536
+ throws JSONException {
537
+ String text = null;
538
+ while (true) {
539
+ state = FlowBridge.nextQueryStep(state, text);
540
+ if (state == null) {
646
541
  return null;
647
542
  }
648
- String response = httpRequest(
649
- base + HttpUtils.QUERY_PATH + appKey, body, deadlineNanos);
650
- if (isValidResponse(response)) {
651
- return response;
543
+ JSONObject step = new JSONObject(state);
544
+ if (step.optBoolean("found")) {
545
+ return text;
652
546
  }
653
- }
654
- JSONArray queryUrls = config.optJSONArray("queryUrls");
655
- if (queryUrls == null) {
656
- return null;
657
- }
658
- for (int i = 0; i < queryUrls.length(); i++) {
659
- String listUrl = queryUrls.optString(i, "");
660
- if (listUrl.isEmpty()) {
661
- continue;
662
- }
663
- if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) {
547
+ String url = step.optString("url", "");
548
+ if (url.isEmpty()) {
664
549
  return null;
665
550
  }
666
- String listText = httpRequest(listUrl, null, deadlineNanos);
667
- if (listText == null) {
668
- continue;
669
- }
670
- JSONArray remote;
671
- try {
672
- remote = new JSONArray(listText);
673
- } catch (JSONException e) {
674
- continue;
675
- }
676
- for (int j = 0; j < remote.length(); j++) {
677
- String base = HttpUtils.normalizeEndpointBase(remote.optString(j, ""));
678
- if (base.isEmpty() || tried.contains(base)) {
679
- continue;
680
- }
681
- if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) {
682
- return null;
683
- }
684
- tried.add(base);
685
- String response = httpRequest(
686
- base + HttpUtils.QUERY_PATH + appKey, body, deadlineNanos);
687
- if (isValidResponse(response)) {
688
- return response;
689
- }
690
- }
691
- // One successfully fetched remote list is enough.
692
- break;
551
+ text = httpRequest(url, step.optBoolean("post") ? body : null, deadlineNanos);
693
552
  }
694
- return null;
695
553
  }
696
554
 
697
555
  private static long capToHoldBudget(long phaseDeadlineNanos, long holdDeadlineNanos) {
@@ -701,9 +559,35 @@ final class SyncCoordinator {
701
559
  return Math.min(phaseDeadlineNanos, holdDeadlineNanos);
702
560
  }
703
561
 
704
- private static boolean performAttempts(
705
- UpdateContext context, JSONArray attempts, String hash, String originHash,
706
- long holdDeadlineNanos
562
+ /** Queues one download attempt; null when it was rejected up front (the listener was told). */
563
+ interface DownloadStarter {
564
+ @Nullable
565
+ DownloadTaskParams start(
566
+ String type, String url, UpdateContext.DownloadFileListener listener, long deadlineNanos);
567
+ }
568
+
569
+ private static DownloadStarter downloadStarter(
570
+ final UpdateContext context, final String hash, final String originHash
571
+ ) {
572
+ return (type, url, listener, deadlineNanos) -> {
573
+ if ("diff".equals(type)) {
574
+ return context.downloadPatchFromPpk(url, hash, originHash, listener, deadlineNanos);
575
+ }
576
+ if ("pdiff".equals(type)) {
577
+ return context.downloadPatchFromApk(url, hash, listener, deadlineNanos);
578
+ }
579
+ return context.downloadFullUpdate(url, hash, listener, deadlineNanos);
580
+ };
581
+ }
582
+
583
+ /**
584
+ * Strategies in the core's order (diff, pdiff, full), each over its
585
+ * mirrors, already planned for this device (FlowBridge.handleResponse:
586
+ * no diff without a running version, no cleartext URL under an
587
+ * https-only config).
588
+ */
589
+ static boolean performAttempts(
590
+ @Nullable JSONArray attempts, long holdDeadlineNanos, DownloadStarter starter
707
591
  ) {
708
592
  if (attempts == null) {
709
593
  return false;
@@ -718,10 +602,6 @@ final class SyncCoordinator {
718
602
  continue;
719
603
  }
720
604
  String type = attempt.optString("type");
721
- if ("diff".equals(type) && (originHash == null || originHash.isEmpty())) {
722
- // diff patches from the running version; none is running.
723
- continue;
724
- }
725
605
  final boolean isFullAttempt = !"diff".equals(type) && !"pdiff".equals(type);
726
606
  if (isFullAttempt && fullDeadlineNanos == 0) {
727
607
  // Incremental failures must not consume the last-resort full
@@ -739,9 +619,6 @@ final class SyncCoordinator {
739
619
  }
740
620
  for (int j = 0; j < urls.length(); j++) {
741
621
  String url = urls.optString(j, "");
742
- if (url.isEmpty()) {
743
- continue;
744
- }
745
622
  // Check before enqueueing: once the phase budget is gone we
746
623
  // must not launch an orphan download that outlives the round.
747
624
  long remainingNanos = deadlineNanos - System.nanoTime();
@@ -753,6 +630,7 @@ final class SyncCoordinator {
753
630
  }
754
631
  final CountDownLatch latch = new CountDownLatch(1);
755
632
  final AtomicBoolean succeeded = new AtomicBoolean(false);
633
+ final AtomicReference<Throwable> failure = new AtomicReference<>();
756
634
  final String attemptType = type;
757
635
  UpdateContext.DownloadFileListener listener =
758
636
  new UpdateContext.DownloadFileListener() {
@@ -766,20 +644,11 @@ final class SyncCoordinator {
766
644
  public void onDownloadFailed(Throwable error) {
767
645
  Log.i(Texts.LOG_TAG, "native sync: " + attemptType
768
646
  + " attempt failed: " + error);
647
+ failure.set(error);
769
648
  latch.countDown();
770
649
  }
771
650
  };
772
- DownloadTaskParams task;
773
- if ("diff".equals(type)) {
774
- task = context.downloadPatchFromPpk(
775
- url, hash, originHash, listener, deadlineNanos);
776
- } else if ("pdiff".equals(type)) {
777
- task = context.downloadPatchFromApk(
778
- url, hash, listener, deadlineNanos);
779
- } else {
780
- task = context.downloadFullUpdate(
781
- url, hash, listener, deadlineNanos);
782
- }
651
+ DownloadTaskParams task = starter.start(type, url, listener, deadlineNanos);
783
652
  try {
784
653
  if (!latch.await(remainingNanos, TimeUnit.NANOSECONDS)) {
785
654
  Log.w(Texts.LOG_TAG,
@@ -806,6 +675,14 @@ final class SyncCoordinator {
806
675
  if (succeeded.get()) {
807
676
  return true;
808
677
  }
678
+ Throwable error = failure.get();
679
+ if (error != null
680
+ && !ErrorCodes.isMirrorRetryable(ErrorCodes.forDownloadFailure(error))) {
681
+ // The bytes arrived but could not be applied: every
682
+ // mirror serves the same artifact, so move on to the
683
+ // next strategy instead of downloading it again.
684
+ break;
685
+ }
809
686
  }
810
687
  }
811
688
  return false;