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,15 +6,15 @@ import android.os.Looper;
6
6
  import android.util.Log;
7
7
  import com.facebook.react.bridge.Arguments;
8
8
  import com.facebook.react.bridge.WritableMap;
9
- import java.io.ByteArrayOutputStream;
10
9
  import java.io.File;
10
+ import java.io.FileOutputStream;
11
11
  import java.io.IOException;
12
12
  import java.io.InputStream;
13
13
  import java.nio.charset.StandardCharsets;
14
+ import java.security.DigestOutputStream;
15
+ import java.security.MessageDigest;
14
16
  import java.util.ArrayList;
15
17
  import java.util.Enumeration;
16
- import java.util.HashMap;
17
- import java.util.Iterator;
18
18
  import java.util.zip.ZipEntry;
19
19
  import okhttp3.Call;
20
20
  import okhttp3.OkHttpClient;
@@ -24,7 +24,6 @@ import okhttp3.ResponseBody;
24
24
  import okio.BufferedSink;
25
25
  import okio.BufferedSource;
26
26
  import okio.Okio;
27
- import org.json.JSONException;
28
27
  import org.json.JSONObject;
29
28
  import org.json.JSONTokener;
30
29
 
@@ -33,7 +32,8 @@ class DownloadTask implements Runnable {
33
32
  private static final String BUNDLE_DELTA_ENTRY =
34
33
  Texts.reveal("3319f0d4b6c56a502c3b10fcdca0de7d4b3307e9");
35
34
 
36
- private static final int DOWNLOAD_CHUNK_SIZE = 4096;
35
+ // Resume sidecars are a few hundred bytes of JSON.
36
+ private static final long MAX_SIDECAR_BYTES = 64 * 1024;
37
37
  // When the server does not report Content-Length we cannot key progress
38
38
  // events on percentage change, so throttle by bytes to avoid flooding the
39
39
  // bridge (e.g. a 20MB chunked download would otherwise emit ~5000 events).
@@ -42,17 +42,13 @@ class DownloadTask implements Runnable {
42
42
  // slow-dripping connection could occupy the single-threaded download
43
43
  // executor indefinitely and starve queued tasks. The call timeout is a
44
44
  // generous upper bound sized for large full-package downloads.
45
- private static final OkHttpClient HTTP_CLIENT = new OkHttpClient.Builder()
45
+ private static final OkHttpClient HTTP_CLIENT = HttpClients.base().newBuilder()
46
46
  .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
47
47
  .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
48
48
  .writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
49
49
  .callTimeout(10, java.util.concurrent.TimeUnit.MINUTES)
50
50
  .build();
51
51
 
52
- static {
53
- NativeCore.ensureLoaded();
54
- }
55
-
56
52
  // Two-phase install (cpp/patch_core/install_record.h): all unpack/patch
57
53
  // work happens in <hash>.staging; the final <hash> directory only ever
58
54
  // appears through an atomic rename after the completion record was
@@ -62,36 +58,32 @@ class DownloadTask implements Runnable {
62
58
  return InstallRecord.stagingDirectoryFor(params.unzipDirectory);
63
59
  }
64
60
 
65
- // SHA-256 of the downloaded archive, computed right before extraction.
61
+ // SHA-256 of the downloaded archive, recorded in the install record.
66
62
  private String artifactSha256 = "";
67
-
63
+ // The archive digest computed while its bytes streamed to disk (a resumed
64
+ // transfer first hashes the prefix already on disk); null when this task
65
+ // did not stream the whole file itself and it must be hashed from disk.
66
+ private String streamedArchiveSha256;
67
+ // Digest of the bundle computed while a full package was extracted;
68
+ // null when the bundle was produced some other way (patch tasks).
69
+ private String stagedBundleSha256;
70
+
71
+ /** What a staged patch archive declared, as the native core consumes it. */
68
72
  private static final class PatchArchiveContents {
69
- final ArrayList<String> entryNames = new ArrayList<String>();
70
- final ArrayList<String> copyFroms = new ArrayList<String>();
71
- final ArrayList<String> copyTos = new ArrayList<String>();
72
- final ArrayList<String> deletes = new ArrayList<String>();
73
- // __diff.json 的 hbcTransform 元数据(按 patch 条目名索引);
74
- // 为空对象/缺失时返回 ""(native 走现状路径)
75
- JSONObject hbcTransform;
76
-
77
- String hbcTransformMetaFor(String patchEntryName) {
78
- if (hbcTransform == null) {
79
- return "";
80
- }
81
- JSONObject meta = hbcTransform.optJSONObject(patchEntryName);
82
- return meta != null ? meta.toString() : "";
73
+ final String[] entryNames;
74
+ // The raw __diff.json bytes (UTF-8): parsed only by the native core
75
+ // (cpp/patch_core/patch_manifest.h), never here.
76
+ final byte[] manifestJson;
77
+
78
+ PatchArchiveContents(ArrayList<String> entryNames, byte[] manifestJson) {
79
+ this.entryNames = entryNames.toArray(new String[0]);
80
+ this.manifestJson = manifestJson;
83
81
  }
84
- // Maps a copy source path ("from") to the CRC32 of the file content,
85
- // when provided by the manifest ("copiesCrc"). Lets the resource
86
- // copier locate the file by content if the path is not present on
87
- // device (APK baseline -> AAB install path shortening).
88
- final HashMap<String, Long> copyCrcs = new HashMap<String, Long>();
89
82
  }
90
83
 
91
84
  private final Context context;
92
85
  private final DownloadTaskParams params;
93
86
  private final Handler mainHandler = new Handler(Looper.getMainLooper());
94
- private final byte[] buffer = new byte[DOWNLOAD_CHUNK_SIZE];
95
87
  private final BundledResourceCopier bundledResourceCopier;
96
88
  private String hash;
97
89
  // Set once downloadFile() returns: failures after this point in a patch
@@ -108,15 +100,12 @@ class DownloadTask implements Runnable {
108
100
  // Cross-platform progress contract: unknown length is reported as
109
101
  // total=0, never a raw -1 (OkHttp's contentLength for chunked/gzip).
110
102
  final long normalizedTotal = total > 0 ? total : 0;
111
- mainHandler.post(new Runnable() {
112
- @Override
113
- public void run() {
114
- WritableMap progress = Arguments.createMap();
115
- progress.putDouble("received", received);
116
- progress.putDouble("total", normalizedTotal);
117
- progress.putString("hash", hash);
118
- UpdateEventEmitter.sendEvent("RCTPushyDownloadProgress", progress);
119
- }
103
+ mainHandler.post(() -> {
104
+ WritableMap progress = Arguments.createMap();
105
+ progress.putDouble("received", received);
106
+ progress.putDouble("total", normalizedTotal);
107
+ progress.putString("hash", hash);
108
+ UpdateEventEmitter.sendEvent("RCTPushyDownloadProgress", progress);
120
109
  });
121
110
  }
122
111
 
@@ -130,7 +119,7 @@ class DownloadTask implements Runnable {
130
119
 
131
120
  static void deleteResumeSidecar(File archive) {
132
121
  File sidecar = resumeSidecarFile(archive);
133
- if (sidecar.exists() && !sidecar.delete() && UpdateContext.DEBUG) {
122
+ if (sidecar.exists() && !sidecar.delete() && BuildConfig.DEBUG) {
134
123
  Log.w(Texts.LOG_TAG, "Failed to delete resume sidecar " + sidecar);
135
124
  }
136
125
  }
@@ -140,9 +129,8 @@ class DownloadTask implements Runnable {
140
129
  return null;
141
130
  }
142
131
  try {
143
- byte[] bytes = readBytes(new java.io.FileInputStream(sidecar));
144
132
  JSONObject meta = (JSONObject) new JSONTokener(
145
- new String(bytes, StandardCharsets.UTF_8)).nextValue();
133
+ UpdateFileUtils.readSmallUtf8(sidecar, MAX_SIDECAR_BYTES)).nextValue();
146
134
  if (url.equals(meta.optString("url"))) {
147
135
  return meta;
148
136
  }
@@ -187,6 +175,7 @@ class DownloadTask implements Runnable {
187
175
 
188
176
  private void downloadFile() throws IOException {
189
177
  this.hash = params.hash;
178
+ streamedArchiveSha256 = null;
190
179
  String url = params.url;
191
180
  File writePath = params.targetFile;
192
181
  File sidecar = resumeSidecarFile(writePath);
@@ -212,19 +201,13 @@ class DownloadTask implements Runnable {
212
201
  }
213
202
  }
214
203
  if (resumeOffset == 0) {
215
- if (writePath.exists() && !writePath.delete()) {
216
- throw new IOException("Failed to replace existing file: " + writePath);
217
- }
218
- deleteResumeSidecar(writePath);
204
+ discardPartial(writePath);
219
205
  }
220
206
 
221
207
  if (!transferArchive(url, writePath, sidecar, resumeMeta, resumeOffset)) {
222
208
  // The server rejected the range for a partial that no longer
223
209
  // matches (416): drop it and retry once from zero.
224
- if (writePath.exists() && !writePath.delete()) {
225
- throw new IOException("Failed to replace existing file: " + writePath);
226
- }
227
- deleteResumeSidecar(writePath);
210
+ discardPartial(writePath);
228
211
  if (!transferArchive(url, writePath, sidecar, null, 0)) {
229
212
  throw new IOException("Server rejected the download range for " + url);
230
213
  }
@@ -232,6 +215,14 @@ class DownloadTask implements Runnable {
232
215
  downloadPhaseCompleted = true;
233
216
  }
234
217
 
218
+ /** Drops a partial download and its sidecar; throws when the bytes stay. */
219
+ private static void discardPartial(File archive) throws IOException {
220
+ if (archive.exists() && !archive.delete()) {
221
+ throw new IOException("Failed to replace existing file: " + archive);
222
+ }
223
+ deleteResumeSidecar(archive);
224
+ }
225
+
235
226
  /**
236
227
  * One HTTP transfer, appending from resumeOffset when the server honours
237
228
  * the range. Returns false only for the retryable stale-partial case
@@ -263,27 +254,13 @@ class DownloadTask implements Runnable {
263
254
  }
264
255
  }
265
256
 
266
- OkHttpClient requestClient = HTTP_CLIENT;
267
- if (params.deadlineNanos > 0) {
268
- long remainingNanos = params.deadlineNanos - System.nanoTime();
269
- if (remainingNanos <= 0) {
270
- throw new IOException("Download deadline expired before start");
271
- }
272
- long remainingMillis = Math.max(
273
- 1L,
274
- java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(remainingNanos)
275
- );
276
- requestClient = HTTP_CLIENT.newBuilder()
277
- .callTimeout(remainingMillis, java.util.concurrent.TimeUnit.MILLISECONDS)
278
- .build();
279
- }
280
-
281
- Call call = requestClient.newCall(builder.build());
257
+ Call call = HttpClients.withDeadline(HTTP_CLIENT, params.deadlineNanos)
258
+ .newCall(builder.build());
282
259
  // Registered so an orchestrated phase timeout can cancel it
283
260
  // (CODE_AUDIT 2.12); cleared once the transfer is over.
284
261
  params.attachCall(call);
285
262
  try (Response response = call.execute()) {
286
- rejectProtocolDowngrade(url, response);
263
+ HttpClients.rejectProtocolDowngrade(url, response);
287
264
  if (response.code() == 416) {
288
265
  long total = resumeMeta == null ? 0 : resumeMeta.optLong("total", 0);
289
266
  if (total > 0 && writePath.length() == total) {
@@ -340,9 +317,7 @@ class DownloadTask implements Runnable {
340
317
  // Destroy the old bytes before the sidecar can vouch for
341
318
  // them with the new validators (a crash between the two
342
319
  // writes must never leave a sidecar describing stale bytes).
343
- if (writePath.exists() && !writePath.delete()) {
344
- throw new IOException("Failed to replace existing file: " + writePath);
345
- }
320
+ discardPartial(writePath);
346
321
  }
347
322
  if (encodedBody) {
348
323
  // No resume across an encoded transfer.
@@ -358,25 +333,34 @@ class DownloadTask implements Runnable {
358
333
  long lastPostedBytes = baseOffset;
359
334
  // Unknown length: the response-time check could only reserve the
360
335
  // margin, so the disk is probed before any write that would run
361
- // past the bytes reserved so far, each probe reserving at least
362
- // the next PROBE bytes (more when one chunk is larger) — no write
363
- // can ever eat into the margin (the archive cap alone is far more
364
- // than the margin). A throw keeps the partial for a later attempt.
336
+ // past the bytes reserved so far (the shared probe plan,
337
+ // ArchiveLimits.planFreeSpaceProbe) — no write can ever eat into
338
+ // the margin. A throw keeps the partial for a later attempt.
365
339
  long freeSpaceReservedUntil = 0;
366
340
 
341
+ // The archive digest is computed as the bytes reach the file, so
342
+ // the install does not re-read the whole archive afterwards.
343
+ MessageDigest digest = UpdateFileUtils.newSha256();
344
+ if (append) {
345
+ UpdateFileUtils.updateDigest(digest, writePath);
346
+ }
367
347
  try (
368
348
  BufferedSource source = body.source();
369
- BufferedSink sink = Okio.buffer(
370
- append ? Okio.appendingSink(writePath) : Okio.sink(writePath))
349
+ BufferedSink sink = Okio.buffer(Okio.sink(new DigestOutputStream(
350
+ new FileOutputStream(writePath, append), digest)))
371
351
  ) {
372
- while ((bytesRead = source.read(sink.buffer(), DOWNLOAD_CHUNK_SIZE)) != -1) {
352
+ while ((bytesRead = source.read(sink.buffer(), UpdateFileUtils.BUFFER_SIZE)) != -1) {
353
+ // A known length was reserved up front: no per-chunk plan
354
+ // (and no JNI round trip on this hot loop).
355
+ long[] probe = totalAll > 0 ? null : ArchiveLimits.planFreeSpaceProbe(
356
+ totalAll, received, bytesRead, freeSpaceReservedUntil);
373
357
  received += bytesRead;
374
- if (totalAll <= 0 && received > freeSpaceReservedUntil) {
375
- long reserve = Math.max(
376
- ArchiveLimits.UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, bytesRead);
377
- freeSpaceReservedUntil = received - bytesRead + reserve;
358
+ if (probe != null) {
359
+ freeSpaceReservedUntil = probe[1];
360
+ }
361
+ if (probe != null && probe[0] > 0) {
378
362
  try {
379
- ArchiveLimits.ensureFreeSpace(writePath, reserve);
363
+ ArchiveLimits.ensureFreeSpace(writePath, probe[0]);
380
364
  } catch (IOException e) {
381
365
  // The chunk is still only buffered; closing the
382
366
  // sink would flush it onto the disk that just
@@ -385,7 +369,9 @@ class DownloadTask implements Runnable {
385
369
  throw e;
386
370
  }
387
371
  }
388
- sink.emit();
372
+ // Write whole segments only: a 64KB read no longer costs
373
+ // a write() syscall per partially filled segment.
374
+ sink.emitCompleteSegments();
389
375
 
390
376
  long overall = baseOffset + received;
391
377
  if (overall > ArchiveLimits.MAX_ARCHIVE_BYTES) {
@@ -415,6 +401,7 @@ class DownloadTask implements Runnable {
415
401
  throw new IOException("Download incomplete: expected " + totalAll
416
402
  + " bytes, got " + writePath.length());
417
403
  }
404
+ streamedArchiveSha256 = UpdateFileUtils.toHex(digest.digest());
418
405
  // Final progress event, skipped when the loop already posted this
419
406
  // exact value (known length reaching 100% posts it in-loop).
420
407
  if (baseOffset + received != lastPostedBytes) {
@@ -426,136 +413,51 @@ class DownloadTask implements Runnable {
426
413
  return true;
427
414
  }
428
415
 
429
- private byte[] readBytes(InputStream input) throws IOException {
430
- return readBytes(input, Long.MAX_VALUE);
431
- }
432
-
433
- private byte[] readBytes(InputStream input, long maxBytes) throws IOException {
434
- try (
435
- InputStream in = input;
436
- ByteArrayOutputStream out = new ByteArrayOutputStream()
437
- ) {
438
- int count;
439
- long total = 0;
440
- while ((count = in.read(buffer)) != -1) {
441
- total += count;
442
- if (total > maxBytes) {
443
- throw new IOException("content exceeds " + maxBytes + " bytes");
444
- }
445
- out.write(buffer, 0, count);
446
- }
447
- return out.toByteArray();
448
- }
449
- }
450
-
451
- private void appendManifestEntries(
452
- JSONObject manifest,
453
- ArrayList<String> copyFroms,
454
- ArrayList<String> copyTos,
455
- ArrayList<String> deletes,
456
- HashMap<String, Long> copyCrcs
457
- ) throws JSONException {
458
- JSONObject copiesCrc = manifest.optJSONObject("copiesCrc");
459
-
460
- JSONObject copies = manifest.optJSONObject("copies");
461
- if (copies != null) {
462
- Iterator<?> keys = copies.keys();
463
- while (keys.hasNext()) {
464
- String to = (String) keys.next();
465
- String from = copies.getString(to);
466
- if (from.isEmpty()) {
467
- from = to;
468
- }
469
- copyFroms.add(from);
470
- copyTos.add(to);
471
- if (copiesCrc != null && copyCrcs != null && copiesCrc.has(to)) {
472
- // Same content => same crc, so grouping multiple "to" under
473
- // one "from" stays consistent.
474
- copyCrcs.put(from, copiesCrc.getLong(to));
475
- }
476
- }
477
- }
478
-
479
- JSONObject deleteMap = manifest.optJSONObject("deletes");
480
- if (deleteMap != null) {
481
- Iterator<?> deleteKeys = deleteMap.keys();
482
- while (deleteKeys.hasNext()) {
483
- deletes.add((String) deleteKeys.next());
484
- }
485
- }
486
- }
487
-
488
416
  private void copyBundledAssetToFile(String assetName, File destination) throws IOException {
489
417
  try (InputStream in = context.getAssets().open(assetName)) {
490
418
  UpdateFileUtils.copyInputStreamToFile(in, destination);
491
419
  }
492
420
  }
493
421
 
494
- private HashMap<String, ArrayList<File>> buildCopyList(
495
- File unzipDirectory,
496
- CopyGroupResult[] groups
497
- ) throws IOException {
498
- HashMap<String, ArrayList<File>> copyList = new HashMap<String, ArrayList<File>>();
499
- if (groups == null) {
500
- return copyList;
501
- }
502
-
503
- String rootPath = unzipDirectory.getCanonicalPath() + File.separator;
504
- for (CopyGroupResult group : groups) {
505
- ArrayList<File> targets = new ArrayList<File>();
506
- if (group.toPaths != null) {
507
- for (String to : group.toPaths) {
508
- File toFile = new File(unzipDirectory, to);
509
- String canonicalPath = toFile.getCanonicalPath();
510
- if (!canonicalPath.startsWith(rootPath)) {
511
- throw new SecurityException("Illegal name: " + to);
512
- }
513
- targets.add(toFile);
514
- }
515
- }
516
- copyList.put(group.from, targets);
517
- }
518
-
519
- return copyList;
520
- }
521
-
522
422
  private PatchArchiveContents extractPatchArchive(File archiveFile, File unzipDirectory)
523
- throws IOException, JSONException {
423
+ throws IOException {
524
424
  UpdateFileUtils.removeDirectory(unzipDirectory);
525
425
  UpdateFileUtils.ensureDirectory(unzipDirectory);
526
426
 
527
- PatchArchiveContents contents = new PatchArchiveContents();
427
+ ArrayList<String> entryNames = new ArrayList<String>();
428
+ byte[] manifestJson = new byte[0];
528
429
  try (SafeZipFile zipFile = new SafeZipFile(archiveFile)) {
529
430
  ensureArchiveWithinLimits(archiveFile, zipFile, unzipDirectory);
530
431
  Enumeration<? extends ZipEntry> entries = zipFile.entries();
531
432
  while (entries.hasMoreElements()) {
532
433
  ZipEntry entry = entries.nextElement();
533
434
  String name = entry.getName();
534
- contents.entryNames.add(name);
435
+ entryNames.add(name);
535
436
 
536
437
  if (name.equals("__diff.json")) {
537
438
  if (entry.getSize() > ArchiveLimits.MAX_MANIFEST_BYTES) {
538
439
  throw new IOException("delta manifest too large: " + entry.getSize());
539
440
  }
540
- byte[] bytes = readBytes(
441
+ manifestJson = UpdateFileUtils.readBytes(
541
442
  zipFile.getInputStream(entry), ArchiveLimits.MAX_MANIFEST_BYTES);
542
- String json = new String(bytes, StandardCharsets.UTF_8);
543
- JSONObject manifest = (JSONObject) new JSONTokener(json).nextValue();
544
- appendManifestEntries(
545
- manifest,
546
- contents.copyFroms,
547
- contents.copyTos,
548
- contents.deletes,
549
- contents.copyCrcs
550
- );
551
- contents.hbcTransform = manifest.optJSONObject("hbcTransform");
552
443
  continue;
553
444
  }
554
445
 
555
446
  zipFile.unzipToPath(entry, unzipDirectory);
556
447
  }
557
448
  }
558
- return contents;
449
+ return new PatchArchiveContents(entryNames, manifestJson);
450
+ }
451
+
452
+ /**
453
+ * Shared first half of the two patch tasks: download the archive and
454
+ * unpack it into the staging directory. The native applyArchiveDelta
455
+ * validates what it declared.
456
+ */
457
+ private PatchArchiveContents stagePatchArchive() throws IOException {
458
+ downloadFile();
459
+ artifactSha256 = archiveSha256();
460
+ return extractPatchArchive(params.targetFile, stagingDirectory());
559
461
  }
560
462
 
561
463
  private void doFullPatch() throws IOException {
@@ -564,25 +466,44 @@ class DownloadTask implements Runnable {
564
466
  File work = stagingDirectory();
565
467
  UpdateFileUtils.removeDirectory(work);
566
468
  UpdateFileUtils.ensureDirectory(work);
567
- artifactSha256 = UpdateFileUtils.sha256Hex(params.targetFile);
568
-
469
+ artifactSha256 = archiveSha256();
470
+
471
+ // The bundle's digest (for the install record) is taken from the
472
+ // bytes as they are extracted, instead of re-reading the bundle right
473
+ // after. Any entry resolving to the bundle path counts; the last write
474
+ // wins on disk and in the digest alike.
475
+ File bundleTarget = InstallRecord.bundleFile(work).getCanonicalFile();
476
+ stagedBundleSha256 = null;
569
477
  try (SafeZipFile zipFile = new SafeZipFile(params.targetFile)) {
570
478
  ensureArchiveWithinLimits(params.targetFile, zipFile, work);
571
479
  Enumeration<? extends ZipEntry> entries = zipFile.entries();
572
480
  while (entries.hasMoreElements()) {
573
- zipFile.unzipToPath(entries.nextElement(), work);
481
+ ZipEntry entry = entries.nextElement();
482
+ if (!entry.isDirectory()
483
+ && new File(work, entry.getName()).getCanonicalFile().equals(bundleTarget)) {
484
+ MessageDigest digest = UpdateFileUtils.newSha256();
485
+ zipFile.unzipToPath(entry, work, digest);
486
+ stagedBundleSha256 = UpdateFileUtils.toHex(digest.digest());
487
+ } else {
488
+ zipFile.unzipToPath(entry, work);
489
+ }
574
490
  }
575
491
  }
576
492
 
577
493
  deleteConsumedArchive();
578
494
  }
579
495
 
496
+ private String archiveSha256() throws IOException {
497
+ return streamedArchiveSha256 != null
498
+ ? streamedArchiveSha256 : UpdateFileUtils.sha256Hex(params.targetFile);
499
+ }
500
+
580
501
  /**
581
502
  * Last step of a successful patch task: the completion record (with the
582
503
  * final bundle's digest) goes into the staging directory, which is then
583
504
  * renamed over the version directory in one atomic step.
584
505
  */
585
- private void promoteStaging() throws IOException, JSONException {
506
+ private void promoteStaging() throws IOException {
586
507
  File work = stagingDirectory();
587
508
  if (targetsRunningVersion()) {
588
509
  // The process switched to this very version while the task ran
@@ -592,25 +513,11 @@ class DownloadTask implements Runnable {
592
513
  UpdateFileUtils.removeDirectory(work);
593
514
  return;
594
515
  }
595
- File bundle = new File(work, "index.bundlejs");
596
- if (!bundle.isFile()) {
597
- throw new IOException("bundle missing after install: " + bundle);
598
- }
599
- String bundleSha256 = UpdateFileUtils.sha256Hex(bundle);
600
- InstallRecord.write(
601
- work, InstallRecord.build(params.hash, bundleSha256, artifactSha256));
516
+ InstallRecord.seal(work, params.hash, artifactSha256, stagedBundleSha256);
602
517
  if (params.unzipDirectory.exists()) {
603
518
  UpdateFileUtils.removeDirectory(params.unzipDirectory);
604
519
  }
605
- if (!work.renameTo(params.unzipDirectory)) {
606
- throw new IOException("failed to promote staging directory to " + params.unzipDirectory);
607
- }
608
- // The rename rewrote the versions root's entries; the record's own
609
- // directory was synced by InstallRecord.write, the root was not.
610
- File versionsRoot = params.unzipDirectory.getParentFile();
611
- if (versionsRoot != null) {
612
- InstallRecord.syncDirectory(versionsRoot);
613
- }
520
+ InstallRecord.promote(work, params.unzipDirectory);
614
521
  }
615
522
 
616
523
  /**
@@ -626,8 +533,7 @@ class DownloadTask implements Runnable {
626
533
  if (archiveBytes > ArchiveLimits.MAX_ARCHIVE_BYTES) {
627
534
  throw new IOException("archive too large: " + archiveBytes + " bytes");
628
535
  }
629
- SafeZipFile.Inspection inspection = zipFile.inspect();
630
- ArchiveLimits.ensureFreeSpace(unzipDirectory, inspection.totalUncompressed);
536
+ ArchiveLimits.ensureFreeSpace(unzipDirectory, zipFile.inspect(archiveBytes));
631
537
  }
632
538
 
633
539
  // The archive and its resume sidecar live and die together: once the
@@ -640,96 +546,78 @@ class DownloadTask implements Runnable {
640
546
  deleteResumeSidecar(params.targetFile);
641
547
  }
642
548
 
643
- private void doPatchFromApk() throws IOException, JSONException {
644
- downloadFile();
549
+ private void doPatchFromApk() throws IOException {
550
+ PatchArchiveContents contents = stagePatchArchive();
645
551
  File work = stagingDirectory();
646
- artifactSha256 = UpdateFileUtils.sha256Hex(params.targetFile);
647
- PatchArchiveContents contents = extractPatchArchive(params.targetFile, work);
648
-
649
- buildArchivePlan(
650
- DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK,
651
- contents.entryNames.toArray(new String[0]),
652
- contents.copyFroms.toArray(new String[0]),
653
- contents.copyTos.toArray(new String[0]),
654
- contents.deletes.toArray(new String[0])
655
- );
656
-
657
- HashMap<String, ArrayList<File>> copyList = buildCopyList(
658
- work,
659
- buildCopyGroups(
660
- contents.copyFroms.toArray(new String[0]),
661
- contents.copyTos.toArray(new String[0])
662
- )
663
- );
664
552
 
665
553
  File originBundleFile = new File(work, ".origin.bundle");
666
554
  copyBundledAssetToFile("index.android.bundle", originBundleFile);
555
+ CopyGroupResult[] copies;
667
556
  try {
668
- applyDeltaFromSource(
557
+ // The APK's resources are zip entries, not files: the core
558
+ // rebuilds the bundle and hands the manifest copies back.
559
+ copies = applyArchiveDelta(
560
+ DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK,
561
+ contents.entryNames,
562
+ contents.manifestJson,
669
563
  work.getAbsolutePath(),
670
564
  work.getAbsolutePath(),
671
565
  originBundleFile.getAbsolutePath(),
672
566
  new File(work, BUNDLE_DELTA_ENTRY).getAbsolutePath(),
673
- new File(work, "index.bundlejs").getAbsolutePath(),
674
- "",
675
- false,
676
- new String[0],
677
- new String[0],
678
- new String[0],
679
- contents.hbcTransformMetaFor(BUNDLE_DELTA_ENTRY)
567
+ InstallRecord.bundleFile(work).getAbsolutePath(),
568
+ true
680
569
  );
681
570
  } finally {
682
571
  originBundleFile.delete();
683
572
  }
684
573
 
685
- bundledResourceCopier.copyFromResource(copyList, contents.copyCrcs);
574
+ bundledResourceCopier.copyFromResource(work, copies);
686
575
  deleteConsumedArchive();
687
576
  }
688
577
 
689
- private void doPatchFromPpk() throws IOException, JSONException {
690
- downloadFile();
578
+ private void doPatchFromPpk() throws IOException {
579
+ PatchArchiveContents contents = stagePatchArchive();
691
580
  File work = stagingDirectory();
692
- artifactSha256 = UpdateFileUtils.sha256Hex(params.targetFile);
693
- PatchArchiveContents contents = extractPatchArchive(params.targetFile, work);
694
-
695
- ArchivePatchPlanResult plan = buildArchivePlan(
581
+ applyArchiveDelta(
696
582
  DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK,
697
- contents.entryNames.toArray(new String[0]),
698
- contents.copyFroms.toArray(new String[0]),
699
- contents.copyTos.toArray(new String[0]),
700
- contents.deletes.toArray(new String[0])
701
- );
702
-
703
- applyDeltaFromSource(
583
+ contents.entryNames,
584
+ contents.manifestJson,
704
585
  params.originDirectory.getAbsolutePath(),
705
586
  work.getAbsolutePath(),
706
- new File(params.originDirectory, "index.bundlejs").getAbsolutePath(),
587
+ InstallRecord.bundleFile(params.originDirectory).getAbsolutePath(),
707
588
  new File(work, BUNDLE_DELTA_ENTRY).getAbsolutePath(),
708
- new File(work, "index.bundlejs").getAbsolutePath(),
709
- plan.mergeSourceSubdir,
710
- plan.enableMerge,
711
- contents.copyFroms.toArray(new String[0]),
712
- contents.copyTos.toArray(new String[0]),
713
- contents.deletes.toArray(new String[0]),
714
- contents.hbcTransformMetaFor(BUNDLE_DELTA_ENTRY)
589
+ InstallRecord.bundleFile(work).getAbsolutePath(),
590
+ false
715
591
  );
716
592
  deleteConsumedArchive();
717
593
  }
718
594
 
595
+ // Dot-named, so the selection under the versions root never picks it.
596
+ static final String TRASH_DIR_NAME = ".trash";
597
+
719
598
  private void doCleanUp() {
720
- UpdateContext.getInstance(context).runCleanupWithLatestState(
721
- new UpdateContext.CleanupAction() {
722
- @Override
723
- public void run(String keepCurrent, String keepPrevious) {
724
- cleanupOldEntries(
725
- params.unzipDirectory.getAbsolutePath(),
726
- keepCurrent,
727
- keepPrevious,
728
- params.maxAgeDays
729
- );
730
- }
599
+ final File trash = new File(params.unzipDirectory, TRASH_DIR_NAME);
600
+ // Phase 1, under the version-files lock: only renames doomed entries
601
+ // into the trash, so activation is never blocked behind a recursive
602
+ // delete of several version directories.
603
+ UpdateContext.getInstance(context).runCleanupWithLatestState(keepNames ->
604
+ cleanupOldEntries(
605
+ params.unzipDirectory.getAbsolutePath(),
606
+ keepNames,
607
+ params.maxAgeDays,
608
+ trash.getAbsolutePath()
609
+ ));
610
+ // Phase 2, lock released: nothing in the trash is reachable as a
611
+ // version any more. Also purges leftovers of an interrupted run.
612
+ // Best effort: phase 1 already succeeded, and a leftover the native
613
+ // delete cannot remove must not fail every later cleanup task.
614
+ if (trash.isDirectory()) {
615
+ try {
616
+ cleanupOldEntries(trash.getAbsolutePath(), new String[0], 0, null);
617
+ } catch (RuntimeException e) {
618
+ Log.w(Texts.LOG_TAG, "Failed to purge cleanup trash", e);
731
619
  }
732
- );
620
+ }
733
621
  }
734
622
 
735
623
  private void cleanUpAfterFailure(int taskType) {
@@ -753,37 +641,13 @@ class DownloadTask implements Runnable {
753
641
  }
754
642
  break;
755
643
  case DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD:
756
- if (
757
- params.targetFile.exists()
758
- && !params.targetFile.delete()
759
- && UpdateContext.DEBUG
760
- ) {
761
- Log.w(Texts.LOG_TAG, "Failed to clean partial download " + params.targetFile);
762
- }
763
- deleteResumeSidecar(params.targetFile);
644
+ deleteConsumedArchive();
764
645
  break;
765
646
  default:
766
647
  break;
767
648
  }
768
649
  }
769
650
 
770
- /**
771
- * An https artifact URL must stay on https through every redirect: the
772
- * package is the supply-chain boundary and TLS is what authenticates
773
- * it. OkHttp follows cross-scheme redirects by default, so the final
774
- * request is checked here (the redirected bytes are discarded).
775
- */
776
- static void rejectProtocolDowngrade(String requestedUrl, Response response)
777
- throws IOException {
778
- if (requestedUrl != null
779
- && requestedUrl.regionMatches(true, 0, "https:", 0, 6)
780
- && !response.request().url().isHttps()) {
781
- throw new IOException(
782
- "https download redirected to plaintext http: "
783
- + response.request().url());
784
- }
785
- }
786
-
787
651
  private boolean isPatchTask(int taskType) {
788
652
  return taskType == DownloadTaskParams.TASK_TYPE_PATCH_FULL
789
653
  || taskType == DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK
@@ -793,8 +657,7 @@ class DownloadTask implements Runnable {
793
657
  private boolean hasCompletedDeltaDirectory() {
794
658
  return params.unzipDirectory != null
795
659
  && params.hash != null
796
- && new File(params.unzipDirectory, "index.bundlejs").isFile()
797
- && InstallRecord.isComplete(params.unzipDirectory, params.hash);
660
+ && InstallRecord.isInstalled(params.unzipDirectory, params.hash);
798
661
  }
799
662
 
800
663
  // True when this task targets the version the process is running from.
@@ -813,18 +676,12 @@ class DownloadTask implements Runnable {
813
676
  * failure rather than overwritten.
814
677
  */
815
678
  private void ensureNotReinstallingRunningVersion() throws IOException {
816
- File bundle = new File(params.unzipDirectory, "index.bundlejs");
817
- if (bundle.isFile() && InstallRecord.isComplete(params.unzipDirectory, params.hash)) {
818
- try {
819
- InstallRecord.verifyForActivation(params.unzipDirectory, params.hash, bundle);
820
- return;
821
- } catch (IOException e) {
822
- throw new FileOperationException("Running version " + params.hash
823
- + " cannot be reinstalled in place: " + e.getMessage());
824
- }
679
+ try {
680
+ InstallRecord.verifyInstalled(params.unzipDirectory, params.hash, false);
681
+ } catch (IOException e) {
682
+ throw new FileOperationException("Running version " + params.hash
683
+ + " cannot be reinstalled in place: " + e.getMessage());
825
684
  }
826
- throw new FileOperationException("Running version " + params.hash
827
- + " has no verifiable install record and cannot be reinstalled in place");
828
685
  }
829
686
 
830
687
  @Override
@@ -917,37 +774,29 @@ class DownloadTask implements Runnable {
917
774
  }
918
775
  }
919
776
 
920
- private static native void applyDeltaFromSource(
777
+ // The native half of a patch install (cpp/patch_core/archive_patch_core.h
778
+ // ApplyArchivePatch): checks the archive's entries, parses and validates
779
+ // manifestJson (__diff.json), rebuilds the bundle and, unless
780
+ // hostCopiesResources, performs the copies, deletes and merge. Returns
781
+ // the copies the caller still owes (empty unless hostCopiesResources).
782
+ private static native CopyGroupResult[] applyArchiveDelta(
783
+ int patchType,
784
+ String[] entryNames,
785
+ byte[] manifestJson,
921
786
  String sourceRoot,
922
787
  String targetRoot,
923
788
  String originBundlePath,
924
789
  String bundlePatchPath,
925
790
  String bundleOutputPath,
926
- String mergeSourceSubdir,
927
- boolean enableMerge,
928
- String[] copyFroms,
929
- String[] copyTos,
930
- String[] deletes,
931
- String hbcTransformMeta
791
+ boolean hostCopiesResources
932
792
  );
933
793
 
794
+ // trashDir non-empty: move the selected entries there instead of
795
+ // deleting them (see doCleanUp).
934
796
  private static native void cleanupOldEntries(
935
797
  String rootDir,
936
- String keepCurrent,
937
- String keepPrevious,
938
- int maxAgeDays
939
- );
940
-
941
- private static native ArchivePatchPlanResult buildArchivePlan(
942
- int patchType,
943
- String[] entryNames,
944
- String[] copyFroms,
945
- String[] copyTos,
946
- String[] deletes
947
- );
948
-
949
- private static native CopyGroupResult[] buildCopyGroups(
950
- String[] copyFroms,
951
- String[] copyTos
798
+ String[] keepNames,
799
+ int maxAgeDays,
800
+ String trashDir
952
801
  );
953
802
  }