react-native-update 10.55.0 → 10.56.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 (76) hide show
  1. package/README-CN.md +35 -0
  2. package/README.md +37 -0
  3. package/android/build.gradle +53 -27
  4. package/android/jni/Application.mk +6 -1
  5. package/android/jni/hpatch.c +59 -4
  6. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  7. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  8. package/android/lib/x86/librnupdate.so +0 -0
  9. package/android/lib/x86_64/librnupdate.so +0 -0
  10. package/android/proguard.pro +70 -14
  11. package/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java +15 -8
  12. package/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java +1 -0
  13. package/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +1 -2
  14. package/android/src/main/java/cn/reactnative/modules/update/CleanupKeepNames.java +25 -0
  15. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +98 -34
  16. package/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +29 -0
  17. package/android/src/main/java/cn/reactnative/modules/update/FileOperationException.java +15 -0
  18. package/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java +59 -0
  19. package/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +27 -1
  20. package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +57 -16
  21. package/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java +38 -46
  22. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +279 -183
  23. package/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java +14 -0
  24. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +222 -215
  25. package/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java +5 -0
  26. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +23 -25
  27. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +29 -29
  28. package/cpp/patch_core/archive_limits.h +6 -0
  29. package/cpp/patch_core/jni_util.h +6 -2
  30. package/cpp/patch_core/patch_core.cpp +237 -84
  31. package/cpp/patch_core/patch_core.h +11 -0
  32. package/cpp/patch_core/patch_core_android.cpp +49 -33
  33. package/cpp/patch_core/update_core_android.cpp +134 -103
  34. package/cpp/update_flow_core/flow_json.cpp +150 -17
  35. package/cpp/update_flow_core/flow_json.h +13 -1
  36. package/cpp/update_flow_core/update_flow_core.cpp +78 -8
  37. package/cpp/update_flow_core/update_flow_core.h +9 -0
  38. package/cpp/update_flow_core/update_flow_jni.cpp +44 -25
  39. package/harmony/hvigor-plugin.ts +168 -21
  40. package/harmony/pushy/src/README.md +74 -0
  41. package/harmony/pushy/src/main/cpp/CMakeLists.txt +13 -3
  42. package/harmony/pushy/src/main/cpp/PushyTurboModule.cpp +11 -5
  43. package/harmony/pushy/src/main/cpp/pushy.cpp +103 -18
  44. package/harmony/pushy/src/main/ets/ArchiveLimits.ts +61 -5
  45. package/harmony/pushy/src/main/ets/DownloadTask.ts +255 -166
  46. package/harmony/pushy/src/main/ets/ErrorCodes.ts +75 -0
  47. package/harmony/pushy/src/main/ets/EventHub.ts +22 -20
  48. package/harmony/pushy/src/main/ets/InstallRecord.ts +79 -21
  49. package/harmony/pushy/src/main/ets/Logger.ts +42 -16
  50. package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +98 -17
  51. package/harmony/pushy/src/main/ets/NativePatchCore.ts +10 -3
  52. package/harmony/pushy/src/main/ets/PathUtils.ts +9 -3
  53. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +139 -53
  54. package/harmony/pushy/src/main/ets/UpdateContext.ts +269 -159
  55. package/harmony/pushy/src/main/module.json5 +5 -1
  56. package/harmony/pushy.har +0 -0
  57. package/ios/PrivacyInfo.xcprivacy +79 -0
  58. package/ios/RCTPushy/RCTPushy.mm +406 -170
  59. package/ios/RCTPushy/RCTPushyDownloader.h +14 -0
  60. package/ios/RCTPushy/RCTPushyDownloader.mm +136 -11
  61. package/package.json +14 -24
  62. package/react-native-update.podspec +36 -3
  63. package/src/client.ts +244 -86
  64. package/src/context.ts +40 -27
  65. package/src/core.ts +41 -16
  66. package/src/endpoint.ts +2 -1
  67. package/src/error.ts +49 -11
  68. package/src/index.ts +14 -0
  69. package/src/locales/en.ts +1 -23
  70. package/src/locales/zh.ts +1 -22
  71. package/src/provider.tsx +159 -111
  72. package/src/type.ts +0 -2
  73. package/src/updateFlowCore.ts +10 -2
  74. package/src/utils.ts +61 -4
  75. package/android/src/main/java/cn/reactnative/modules/update/ReactNativeHostHandler.java +0 -13
  76. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleSupport.java +0 -45
@@ -16,6 +16,7 @@ import java.util.Enumeration;
16
16
  import java.util.HashMap;
17
17
  import java.util.Iterator;
18
18
  import java.util.zip.ZipEntry;
19
+ import okhttp3.Call;
19
20
  import okhttp3.OkHttpClient;
20
21
  import okhttp3.Request;
21
22
  import okhttp3.Response;
@@ -180,31 +181,6 @@ class DownloadTask implements Runnable {
180
181
  }
181
182
  }
182
183
 
183
- // "bytes <start>-<end>/<total>". Returns the total (0 when "*"), or -1
184
- // when the header is missing/malformed or the start does not match the
185
- // local partial — either way the appended bytes could not be trusted.
186
- private static long parseContentRange(String header, long expectedStart) {
187
- if (header == null || !header.startsWith("bytes ")) {
188
- return -1;
189
- }
190
- try {
191
- String range = header.substring("bytes ".length()).trim();
192
- int slash = range.indexOf('/');
193
- int dash = range.indexOf('-');
194
- if (slash < 0 || dash < 0 || dash > slash) {
195
- return -1;
196
- }
197
- long start = Long.parseLong(range.substring(0, dash).trim());
198
- if (start != expectedStart) {
199
- return -1;
200
- }
201
- String totalPart = range.substring(slash + 1).trim();
202
- return totalPart.equals("*") ? 0 : Long.parseLong(totalPart);
203
- } catch (NumberFormatException e) {
204
- return -1;
205
- }
206
- }
207
-
208
184
  private void downloadFile() throws IOException {
209
185
  this.hash = params.hash;
210
186
  String url = params.url;
@@ -298,7 +274,11 @@ class DownloadTask implements Runnable {
298
274
  .build();
299
275
  }
300
276
 
301
- try (Response response = requestClient.newCall(builder.build()).execute()) {
277
+ Call call = requestClient.newCall(builder.build());
278
+ // Registered so an orchestrated phase timeout can cancel it
279
+ // (CODE_AUDIT 2.12); cleared once the transfer is over.
280
+ params.attachCall(call);
281
+ try (Response response = call.execute()) {
302
282
  rejectProtocolDowngrade(url, response);
303
283
  if (response.code() == 416) {
304
284
  long total = resumeMeta == null ? 0 : resumeMeta.optLong("total", 0);
@@ -335,7 +315,8 @@ class DownloadTask implements Runnable {
335
315
  long contentLength = body.contentLength();
336
316
  long totalAll;
337
317
  if (append) {
338
- totalAll = parseContentRange(response.header("Content-Range"), resumeOffset);
318
+ totalAll = HttpUtils.parseContentRange(
319
+ response.header("Content-Range"), resumeOffset);
339
320
  if (totalAll < 0) {
340
321
  // Malformed or mismatched Content-Range: treat like a
341
322
  // stale partial (one clean retry from zero) instead of
@@ -371,6 +352,13 @@ class DownloadTask implements Runnable {
371
352
  long received = 0;
372
353
  int currentPercentage = 0;
373
354
  long lastPostedBytes = baseOffset;
355
+ // Unknown length: the response-time check could only reserve the
356
+ // margin, so the disk is probed before any write that would run
357
+ // past the bytes reserved so far, each probe reserving at least
358
+ // the next PROBE bytes (more when one chunk is larger) — no write
359
+ // can ever eat into the margin (the archive cap alone is far more
360
+ // than the margin). A throw keeps the partial for a later attempt.
361
+ long freeSpaceReservedUntil = 0;
374
362
 
375
363
  try (
376
364
  BufferedSource source = body.source();
@@ -379,6 +367,20 @@ class DownloadTask implements Runnable {
379
367
  ) {
380
368
  while ((bytesRead = source.read(sink.buffer(), DOWNLOAD_CHUNK_SIZE)) != -1) {
381
369
  received += bytesRead;
370
+ if (totalAll <= 0 && received > freeSpaceReservedUntil) {
371
+ long reserve = Math.max(
372
+ ArchiveLimits.UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, bytesRead);
373
+ freeSpaceReservedUntil = received - bytesRead + reserve;
374
+ try {
375
+ ArchiveLimits.ensureFreeSpace(writePath, reserve);
376
+ } catch (IOException e) {
377
+ // The chunk is still only buffered; closing the
378
+ // sink would flush it onto the disk that just
379
+ // failed the probe.
380
+ sink.buffer().clear();
381
+ throw e;
382
+ }
383
+ }
382
384
  sink.emit();
383
385
 
384
386
  long overall = baseOffset + received;
@@ -414,6 +416,8 @@ class DownloadTask implements Runnable {
414
416
  if (baseOffset + received != lastPostedBytes) {
415
417
  postProgress(baseOffset + received, totalAll);
416
418
  }
419
+ } finally {
420
+ params.attachCall(null);
417
421
  }
418
422
  return true;
419
423
  }
@@ -576,6 +580,14 @@ class DownloadTask implements Runnable {
576
580
  */
577
581
  private void promoteStaging() throws IOException, JSONException {
578
582
  File work = stagingDirectory();
583
+ if (targetsRunningVersion()) {
584
+ // The process switched to this very version while the task ran
585
+ // (a restartApp racing the download): never rename over the
586
+ // live directory. Its verified install stands; staging goes.
587
+ ensureNotReinstallingRunningVersion();
588
+ UpdateFileUtils.removeDirectory(work);
589
+ return;
590
+ }
579
591
  File bundle = new File(work, "index.bundlejs");
580
592
  if (!bundle.isFile()) {
581
593
  throw new IOException("bundle missing after install: " + bundle);
@@ -589,6 +601,12 @@ class DownloadTask implements Runnable {
589
601
  if (!work.renameTo(params.unzipDirectory)) {
590
602
  throw new IOException("failed to promote staging directory to " + params.unzipDirectory);
591
603
  }
604
+ // The rename rewrote the versions root's entries; the record's own
605
+ // directory was synced by InstallRecord.write, the root was not.
606
+ File versionsRoot = params.unzipDirectory.getParentFile();
607
+ if (versionsRoot != null) {
608
+ InstallRecord.syncDirectory(versionsRoot);
609
+ }
592
610
  }
593
611
 
594
612
  /**
@@ -695,11 +713,18 @@ class DownloadTask implements Runnable {
695
713
  }
696
714
 
697
715
  private void doCleanUp() {
698
- cleanupOldEntries(
699
- params.unzipDirectory.getAbsolutePath(),
700
- params.hash,
701
- params.originHash,
702
- params.maxAgeDays
716
+ UpdateContext.getInstance(context).runCleanupWithLatestState(
717
+ new UpdateContext.CleanupAction() {
718
+ @Override
719
+ public void run(String keepCurrent, String keepPrevious) {
720
+ cleanupOldEntries(
721
+ params.unzipDirectory.getAbsolutePath(),
722
+ keepCurrent,
723
+ keepPrevious,
724
+ params.maxAgeDays
725
+ );
726
+ }
727
+ }
703
728
  );
704
729
  }
705
730
 
@@ -768,13 +793,51 @@ class DownloadTask implements Runnable {
768
793
  && InstallRecord.isComplete(params.unzipDirectory, params.hash);
769
794
  }
770
795
 
796
+ // True when this task targets the version the process is running from.
797
+ private boolean targetsRunningVersion() {
798
+ String running = UpdateContext.runningVersion();
799
+ return running != null && params.hash != null && running.equals(params.hash);
800
+ }
801
+
802
+ /**
803
+ * The running version's directory is never replaced in place
804
+ * (CODE_AUDIT 2.10): images and fonts are read from it on demand, and an
805
+ * install from before completion records existed has no marker, so a
806
+ * re-download of the same hash would wipe the live directory. A running
807
+ * version whose record verifies against the bundle on disk needs no
808
+ * install and passes; anything else is refused as a file-operation
809
+ * failure rather than overwritten.
810
+ */
811
+ private void ensureNotReinstallingRunningVersion() throws IOException {
812
+ File bundle = new File(params.unzipDirectory, "index.bundlejs");
813
+ if (bundle.isFile() && InstallRecord.isComplete(params.unzipDirectory, params.hash)) {
814
+ try {
815
+ InstallRecord.verifyForActivation(params.unzipDirectory, params.hash, bundle);
816
+ return;
817
+ } catch (IOException e) {
818
+ throw new FileOperationException("Running version " + params.hash
819
+ + " cannot be reinstalled in place: " + e.getMessage());
820
+ }
821
+ }
822
+ throw new FileOperationException("Running version " + params.hash
823
+ + " has no verifiable install record and cannot be reinstalled in place");
824
+ }
825
+
771
826
  @Override
772
827
  public void run() {
773
828
  int taskType = params.type;
829
+ final boolean runningVersion = isPatchTask(taskType) && targetsRunningVersion();
774
830
  final boolean alreadyCompleted = isPatchTask(taskType)
775
831
  && hasCompletedPatchDirectory();
776
832
  try {
777
- if (alreadyCompleted) {
833
+ if (params.isCancelled()) {
834
+ throw new IOException("download task cancelled before it started");
835
+ }
836
+ if (runningVersion) {
837
+ ensureNotReinstallingRunningVersion();
838
+ Log.i(UpdateContext.TAG, "download task: version " + params.hash
839
+ + " is running in this process and already installed");
840
+ } else if (alreadyCompleted) {
778
841
  Log.i(UpdateContext.TAG,
779
842
  "download task: version " + params.hash + " already completed");
780
843
  } else {
@@ -818,7 +881,8 @@ class DownloadTask implements Runnable {
818
881
  Throwable classified = error;
819
882
  if (downloadPhaseCompleted
820
883
  && isPatchTask(taskType)
821
- && !(error instanceof PatchFailedException)) {
884
+ && !(error instanceof PatchFailedException)
885
+ && !(error instanceof FileOperationException)) {
822
886
  classified = new PatchFailedException(
823
887
  String.valueOf(error.getMessage()), error);
824
888
  }
@@ -27,4 +27,33 @@ class DownloadTaskParams {
27
27
  File unzipDirectory;
28
28
  File originDirectory;
29
29
  UpdateContext.DownloadFileListener listener;
30
+
31
+ // Cooperative cancellation for orchestrated downloads (CODE_AUDIT 2.12):
32
+ // the native check cancels a task whose phase budget expired, so it
33
+ // neither starts late on the single download thread nor keeps
34
+ // transferring behind the next attempt. Guarded by this.
35
+ private boolean cancelled;
36
+ private okhttp3.Call activeCall;
37
+
38
+ synchronized void cancel() {
39
+ cancelled = true;
40
+ if (activeCall != null) {
41
+ activeCall.cancel();
42
+ }
43
+ }
44
+
45
+ synchronized boolean isCancelled() {
46
+ return cancelled;
47
+ }
48
+
49
+ /**
50
+ * Registers the transfer in flight (null clears it); a task cancelled
51
+ * before its transfer started cancels the call right here.
52
+ */
53
+ synchronized void attachCall(okhttp3.Call call) {
54
+ activeCall = call;
55
+ if (cancelled && call != null) {
56
+ call.cancel();
57
+ }
58
+ }
30
59
  }
@@ -0,0 +1,15 @@
1
+ package cn.reactnative.modules.update;
2
+
3
+ import java.io.IOException;
4
+
5
+ /**
6
+ * A download task refused by a local file-system rule rather than by the
7
+ * network or the patch itself — today: reinstalling the version this process
8
+ * is running from in place (DownloadTask.ensureNotReinstallingRunningVersion).
9
+ * UpdateModuleImpl rejects these with FILE_OPERATION_FAILED.
10
+ */
11
+ class FileOperationException extends IOException {
12
+ FileOperationException(String message) {
13
+ super(message);
14
+ }
15
+ }
@@ -0,0 +1,59 @@
1
+ package cn.reactnative.modules.update;
2
+
3
+ /**
4
+ * Pure string helpers for the HTTP paths (no Android or OkHttp types), kept
5
+ * apart from DownloadTask / NativeCheckOrchestrator so they stay testable on
6
+ * a plain JVM: DownloadTask loads librnupdate.so in its static initializer
7
+ * and both pull in OkHttp.
8
+ */
9
+ final class HttpUtils {
10
+ private HttpUtils() {
11
+ }
12
+
13
+ /** True for an "https:" URL, case-insensitively; false for anything else (incl. null). */
14
+ static boolean isHttpsUrl(String url) {
15
+ return url != null && url.regionMatches(true, 0, "https://", 0, 8);
16
+ }
17
+
18
+ /** Strips every trailing slash so "<base>/checkUpdate/<appKey>" never doubles one. */
19
+ static String normalizeEndpointBase(String base) {
20
+ while (base.endsWith("/")) {
21
+ base = base.substring(0, base.length() - 1);
22
+ }
23
+ return base;
24
+ }
25
+
26
+ // "bytes <start>-<end>/<total>". Returns the total (0 when "*"), or -1
27
+ // when the header is missing/malformed, the start does not match the
28
+ // local partial, or the range and total contradict each other (end
29
+ // before start, or a numeric total not beyond the end) — either way the
30
+ // appended bytes could not be trusted. A total of 0 would otherwise pass
31
+ // as "unknown" and skip the final size check (RFC 9110 §14.4: the
32
+ // complete length must exceed the last position).
33
+ static long parseContentRange(String header, long expectedStart) {
34
+ if (header == null || !header.startsWith("bytes ")) {
35
+ return -1;
36
+ }
37
+ try {
38
+ String range = header.substring("bytes ".length()).trim();
39
+ int slash = range.indexOf('/');
40
+ int dash = range.indexOf('-');
41
+ if (slash < 0 || dash < 0 || dash > slash) {
42
+ return -1;
43
+ }
44
+ long start = Long.parseLong(range.substring(0, dash).trim());
45
+ long end = Long.parseLong(range.substring(dash + 1, slash).trim());
46
+ if (start != expectedStart || end < start) {
47
+ return -1;
48
+ }
49
+ String totalPart = range.substring(slash + 1).trim();
50
+ if (totalPart.equals("*")) {
51
+ return 0;
52
+ }
53
+ long total = Long.parseLong(totalPart);
54
+ return total > end ? total : -1;
55
+ } catch (NumberFormatException e) {
56
+ return -1;
57
+ }
58
+ }
59
+ }
@@ -1,5 +1,7 @@
1
1
  package cn.reactnative.modules.update;
2
2
 
3
+ import android.os.Build;
4
+ import android.util.Log;
3
5
  import java.io.File;
4
6
  import java.io.FileInputStream;
5
7
  import java.io.FileOutputStream;
@@ -39,7 +41,11 @@ final class InstallRecord {
39
41
  return record.toString();
40
42
  }
41
43
 
42
- /** Writes the record and fsyncs it: the rename that follows must find it on disk. */
44
+ /**
45
+ * Writes the record and fsyncs it, then the directory holding it: the
46
+ * rename that follows must find the record on disk, and the new
47
+ * directory entry is only durable once the directory itself is synced.
48
+ */
43
49
  static void write(File versionDir, String recordJson) throws IOException {
44
50
  File file = new File(versionDir, FILE_NAME);
45
51
  try (FileOutputStream out = new FileOutputStream(file)) {
@@ -47,6 +53,26 @@ final class InstallRecord {
47
53
  out.flush();
48
54
  out.getFD().sync();
49
55
  }
56
+ syncDirectory(versionDir);
57
+ }
58
+
59
+ // java.nio.file (the only way to open a directory for fsync from Java)
60
+ // is API 26+ on Android; below that the file fsync above is all there
61
+ // is. Best-effort by design: a file system that refuses to open a
62
+ // directory for reading must not fail the install. Also called by the
63
+ // installer on the versions root after the staging -> version rename:
64
+ // that rename changes the parent's entries, which only the parent's own
65
+ // sync makes durable.
66
+ static void syncDirectory(File dir) {
67
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
68
+ return;
69
+ }
70
+ try (java.nio.channels.FileChannel channel = java.nio.channels.FileChannel.open(
71
+ dir.toPath(), java.nio.file.StandardOpenOption.READ)) {
72
+ channel.force(true);
73
+ } catch (Throwable e) {
74
+ Log.w("pushy", "Directory fsync skipped for " + dir + ": " + e);
75
+ }
50
76
  }
51
77
 
52
78
  /**
@@ -2,6 +2,10 @@ package cn.reactnative.modules.update;
2
2
 
3
3
  import android.os.Build;
4
4
  import android.util.Log;
5
+ import androidx.annotation.Nullable;
6
+ import java.io.IOException;
7
+ import java.nio.charset.Charset;
8
+ import java.nio.charset.StandardCharsets;
5
9
  import java.util.HashSet;
6
10
  import java.util.concurrent.CountDownLatch;
7
11
  import java.util.concurrent.TimeUnit;
@@ -11,6 +15,8 @@ import okhttp3.OkHttpClient;
11
15
  import okhttp3.Request;
12
16
  import okhttp3.RequestBody;
13
17
  import okhttp3.Response;
18
+ import okhttp3.ResponseBody;
19
+ import okio.BufferedSource;
14
20
  import org.json.JSONArray;
15
21
  import org.json.JSONException;
16
22
  import org.json.JSONObject;
@@ -35,6 +41,9 @@ final class NativeCheckOrchestrator {
35
41
  static final String KEY_ROUND_INCOMPLETE = "nativeCheckIncomplete";
36
42
  private static final int MAX_CHECK_HTTP_ATTEMPTS = 8;
37
43
  private static final long DOWNLOAD_PHASE_TIMEOUT_SECONDS = 600;
44
+ // The check response (and a remote queryUrls list) is a small JSON
45
+ // document; anything bigger is a broken or hijacked endpoint.
46
+ private static final long MAX_CHECK_RESPONSE_BYTES = 1024 * 1024;
38
47
 
39
48
  private static final AtomicBoolean scheduled = new AtomicBoolean(false);
40
49
  // One round per process, whoever starts it first — the delayed cold-start
@@ -177,7 +186,8 @@ final class NativeCheckOrchestrator {
177
186
 
178
187
  // The alert-strategy variant of the §10.7 hole: the round downloaded a
179
188
  // fix but deferred activation to JS, and JS is now dead. Activation is
180
- // local and bounded (a state switch under the commit lock).
189
+ // local and bounded (the bundle digest, then a state switch under the
190
+ // commit lock).
181
191
  private static void activatePendingVersion(UpdateContext context) {
182
192
  String hash = unactivatedHash;
183
193
  if (hash == null) {
@@ -341,7 +351,7 @@ final class NativeCheckOrchestrator {
341
351
  return;
342
352
  }
343
353
  String hash = decision.optString("hash", "");
344
- if (!UpdateContext.isSafePathComponent(hash)) {
354
+ if (!UpdateFileUtils.isSafePathComponent(hash)) {
345
355
  return;
346
356
  }
347
357
 
@@ -467,16 +477,43 @@ final class NativeCheckOrchestrator {
467
477
  // Same rule as the artifact download: an https endpoint that
468
478
  // redirects to plaintext http is a failed endpoint.
469
479
  DownloadTask.rejectProtocolDowngrade(url, response);
470
- if (!response.isSuccessful() || response.body() == null) {
480
+ ResponseBody body = response.body();
481
+ if (!response.isSuccessful() || body == null) {
471
482
  return null;
472
483
  }
473
- return response.body().string();
484
+ return readBoundedBody(body);
474
485
  }
475
486
  } catch (Exception e) {
487
+ // One line per failed endpoint; the fallback chain is otherwise
488
+ // invisible in the field.
489
+ Log.w(UpdateContext.TAG, "native check: request failed with "
490
+ + e.getClass().getName() + ": " + e.getMessage());
476
491
  return null;
477
492
  }
478
493
  }
479
494
 
495
+ /**
496
+ * Reads the body with a hard cap (CODE_AUDIT 2.7): a hijacked endpoint
497
+ * must not be able to OOM this thread with an unbounded string. Over the
498
+ * cap counts as a failed endpoint (null), like any other bad response.
499
+ */
500
+ @Nullable
501
+ private static String readBoundedBody(ResponseBody body) throws IOException {
502
+ if (body.contentLength() > MAX_CHECK_RESPONSE_BYTES) {
503
+ return null;
504
+ }
505
+ BufferedSource source = body.source();
506
+ if (source.request(MAX_CHECK_RESPONSE_BYTES + 1)) {
507
+ Log.w(UpdateContext.TAG, "native check: response exceeds "
508
+ + MAX_CHECK_RESPONSE_BYTES + " bytes, ignoring endpoint");
509
+ return null;
510
+ }
511
+ MediaType contentType = body.contentType();
512
+ Charset charset = contentType == null
513
+ ? null : contentType.charset(StandardCharsets.UTF_8);
514
+ return source.readString(charset == null ? StandardCharsets.UTF_8 : charset);
515
+ }
516
+
480
517
  // Shared schema rule (update_flow_core::IsValidCheckResponse): a 200 with
481
518
  // `{"error": ...}` is a failed endpoint, not a verdict, and must not stop
482
519
  // the endpoint fallback.
@@ -484,13 +521,6 @@ final class NativeCheckOrchestrator {
484
521
  return responseText != null && NativeUpdateFlow.isValidCheckResponse(responseText);
485
522
  }
486
523
 
487
- private static String normalizeEndpointBase(String base) {
488
- while (base.endsWith("/")) {
489
- base = base.substring(0, base.length() - 1);
490
- }
491
- return base;
492
- }
493
-
494
524
  /**
495
525
  * Sequential fallback over the ordered candidates (§5.1): one request at
496
526
  * a time with its own timeout; after the configured round fails,
@@ -513,7 +543,7 @@ final class NativeCheckOrchestrator {
513
543
  HashSet<String> tried = new HashSet<>();
514
544
  int httpAttempts = 0;
515
545
  for (int i = 0; i < ordered.length(); i++) {
516
- String base = normalizeEndpointBase(ordered.optString(i, ""));
546
+ String base = HttpUtils.normalizeEndpointBase(ordered.optString(i, ""));
517
547
  if (base.isEmpty() || !tried.add(base)) {
518
548
  continue;
519
549
  }
@@ -549,7 +579,7 @@ final class NativeCheckOrchestrator {
549
579
  continue;
550
580
  }
551
581
  for (int j = 0; j < remote.length(); j++) {
552
- String base = normalizeEndpointBase(remote.optString(j, ""));
582
+ String base = HttpUtils.normalizeEndpointBase(remote.optString(j, ""));
553
583
  if (base.isEmpty() || tried.contains(base)) {
554
584
  continue;
555
585
  }
@@ -644,20 +674,28 @@ final class NativeCheckOrchestrator {
644
674
  latch.countDown();
645
675
  }
646
676
  };
677
+ DownloadTaskParams task;
647
678
  if ("diff".equals(type)) {
648
- context.downloadPatchFromPpk(
679
+ task = context.downloadPatchFromPpk(
649
680
  url, hash, originHash, listener, deadlineNanos);
650
681
  } else if ("pdiff".equals(type)) {
651
- context.downloadPatchFromApk(
682
+ task = context.downloadPatchFromApk(
652
683
  url, hash, listener, deadlineNanos);
653
684
  } else {
654
- context.downloadFullUpdate(
685
+ task = context.downloadFullUpdate(
655
686
  url, hash, listener, deadlineNanos);
656
687
  }
657
688
  try {
658
689
  if (!latch.await(remainingNanos, TimeUnit.NANOSECONDS)) {
659
690
  Log.w(UpdateContext.TAG,
660
691
  "native check: download phase timed out during " + type);
692
+ // The task shares one download thread with the next
693
+ // attempt: cancel its transfer (or keep it from
694
+ // starting) instead of queueing behind it
695
+ // (CODE_AUDIT 2.12).
696
+ if (task != null) {
697
+ task.cancel();
698
+ }
661
699
  if (isFullAttempt) {
662
700
  return false;
663
701
  }
@@ -665,6 +703,9 @@ final class NativeCheckOrchestrator {
665
703
  }
666
704
  } catch (InterruptedException e) {
667
705
  Thread.currentThread().interrupt();
706
+ if (task != null) {
707
+ task.cancel();
708
+ }
668
709
  return false;
669
710
  }
670
711
  if (succeeded.get()) {
@@ -18,18 +18,35 @@ final class ReactReloadManager {
18
18
  private ReactReloadManager() {
19
19
  }
20
20
 
21
- static void restartApp(
22
- UpdateContext updateContext,
23
- ReactApplicationContext reactContext,
24
- @Nullable String hash
25
- ) throws Throwable {
21
+ /**
22
+ * First half of a restart, for the state serial thread: activate the
23
+ * requested version (bundle digest + commit) and resolve the bundle to
24
+ * load. Nothing here touches React, so nothing here needs the UI thread
25
+ * (CODE_AUDIT 2.2). Returns the bundle path, or null for the packaged
26
+ * bundle.
27
+ */
28
+ @Nullable
29
+ static String prepareRestart(UpdateContext updateContext, @Nullable String hash) {
26
30
  if (hash != null) {
27
31
  updateContext.switchVersion(hash);
28
32
  }
33
+ return updateContext.getBundleUrl();
34
+ }
29
35
 
36
+ /**
37
+ * Second half, for the UI thread: point the running React instance at
38
+ * {@code updateBundlePath} and reload it. Throws when no supported
39
+ * injection worked — the caller rejects RESTART_FAILED. There is
40
+ * deliberately no Activity.recreate() fallback: it would reload the
41
+ * previous bundle while the promise reported success (CODE_AUDIT 2.9).
42
+ */
43
+ static void reload(
44
+ UpdateContext updateContext,
45
+ ReactApplicationContext reactContext,
46
+ @Nullable String updateBundlePath
47
+ ) throws Throwable {
30
48
  Context application = reactContext.getApplicationContext();
31
49
  Activity currentActivity = reactContext.getCurrentActivity();
32
- String updateBundlePath = updateContext.getBundleUrl();
33
50
 
34
51
  boolean newArchitectureEnabled = isNewArchitectureEnabled(application);
35
52
  Object reactHost = newArchitectureEnabled ? getReactHost(currentActivity, application) : null;
@@ -38,53 +55,28 @@ final class ReactReloadManager {
38
55
  reloadReactHost(reactHost, createBundleLoader(application, updateBundlePath, true));
39
56
  return;
40
57
  } catch (Throwable err) {
41
- Log.e(UpdateContext.TAG, "Failed to reload via ReactHost", err);
58
+ Log.e(UpdateContext.TAG,
59
+ "Failed to reload via ReactHost, trying ReactInstanceManager", err);
42
60
  }
43
61
  }
44
62
 
45
63
  JSBundleLoader loader = createBundleLoader(application, updateBundlePath, false);
46
- try {
47
- ReactInstanceManager instanceManager =
48
- resolveReactInstanceManager(updateContext, application);
64
+ ReactInstanceManager instanceManager =
65
+ resolveReactInstanceManager(updateContext, application);
49
66
 
50
- try {
51
- Field loadField = instanceManager.getClass().getDeclaredField("mBundleLoader");
52
- loadField.setAccessible(true);
53
- loadField.set(instanceManager, loader);
54
- } catch (Throwable err) {
55
- Field jsBundleField = instanceManager.getClass().getDeclaredField("mJSBundleFile");
56
- jsBundleField.setAccessible(true);
57
- jsBundleField.set(instanceManager, updateContext.getBundleUrl());
58
- }
59
-
60
- instanceManager.recreateReactContextInBackground();
67
+ // Java (mBundleLoader / mJSBundleFile) and Kotlin (bundleLoader /
68
+ // jsBundleFile) spellings of the private fields are both tried.
69
+ try {
70
+ Field loadField = getCompatibleField(instanceManager.getClass(), "bundleLoader");
71
+ loadField.setAccessible(true);
72
+ loadField.set(instanceManager, loader);
61
73
  } catch (Throwable err) {
62
- if (currentActivity == null) {
63
- throw err;
64
- }
65
-
66
- try {
67
- if (!newArchitectureEnabled) {
68
- throw err;
69
- }
70
- Object currentReactHost = getReactHost(currentActivity, application);
71
- if (currentReactHost == null) {
72
- throw err;
73
- }
74
- reloadReactHost(
75
- currentReactHost,
76
- createBundleLoader(application, updateBundlePath, true)
77
- );
78
- } catch (Throwable reloadError) {
79
- Log.e(
80
- UpdateContext.TAG,
81
- "Failed to reload via ReactHost, falling back to Activity.recreate() "
82
- + "(this may load the previous bundle until next cold start)",
83
- reloadError
84
- );
85
- currentActivity.recreate();
86
- }
74
+ Field jsBundleField = getCompatibleField(instanceManager.getClass(), "jsBundleFile");
75
+ jsBundleField.setAccessible(true);
76
+ jsBundleField.set(instanceManager, updateBundlePath);
87
77
  }
78
+
79
+ instanceManager.recreateReactContextInBackground();
88
80
  }
89
81
 
90
82
  private static Field getCompatibleField(Class<?> clazz, String fieldName)