react-native-update 10.51.0 → 10.52.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.
- package/NATIVE_CHECKUPDATE_DESIGN.md +108 -0
- package/NATIVE_CHECK_FOLLOWUPS.md +46 -0
- package/README-CN.md +2 -0
- package/README.md +2 -0
- package/android/build.gradle +10 -0
- package/android/src/main/java/cn/reactnative/modules/update/CrashRescue.java +115 -0
- package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +259 -26
- package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +209 -19
- package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +6 -0
- package/harmony/pushy/src/main/ets/DownloadTask.ts +381 -72
- package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +32 -2
- package/harmony/pushy/src/main/ets/UpdateContext.ts +5 -0
- package/harmony/pushy.har +0 -0
- package/ios/RCTPushy/RCTPushy.mm +308 -25
- package/ios/RCTPushy/RCTPushyDownloader.mm +361 -74
- package/package.json +1 -1
- package/src/client.ts +8 -0
- package/src/type.ts +6 -0
|
@@ -103,11 +103,173 @@ class DownloadTask implements Runnable {
|
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// Sidecar next to the archive recording what a partial download belongs
|
|
107
|
+
// to (url + validators + total). An archive without a matching sidecar is
|
|
108
|
+
// untrusted and restarted from zero; the pair is deleted together with
|
|
109
|
+
// the archive once it is consumed or classified as poisoned.
|
|
110
|
+
private static File resumeSidecarFile(File archive) {
|
|
111
|
+
return new File(archive.getPath() + ".resume");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
static void deleteResumeSidecar(File archive) {
|
|
115
|
+
File sidecar = resumeSidecarFile(archive);
|
|
116
|
+
if (sidecar.exists() && !sidecar.delete() && UpdateContext.DEBUG) {
|
|
117
|
+
Log.w(UpdateContext.TAG, "Failed to delete resume sidecar " + sidecar);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private JSONObject readResumeMeta(File sidecar, String url) {
|
|
122
|
+
if (!sidecar.isFile()) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
byte[] bytes = readBytes(new java.io.FileInputStream(sidecar));
|
|
127
|
+
JSONObject meta = (JSONObject) new JSONTokener(
|
|
128
|
+
new String(bytes, StandardCharsets.UTF_8)).nextValue();
|
|
129
|
+
if (url.equals(meta.optString("url"))) {
|
|
130
|
+
return meta;
|
|
131
|
+
}
|
|
132
|
+
} catch (Throwable e) {
|
|
133
|
+
// A corrupt sidecar simply means "cannot resume".
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private void writeResumeMeta(
|
|
139
|
+
File sidecar, String url, Response response, JSONObject previousMeta, long total
|
|
140
|
+
) {
|
|
141
|
+
try {
|
|
142
|
+
JSONObject meta = new JSONObject();
|
|
143
|
+
meta.put("url", url);
|
|
144
|
+
String etag = response.header("ETag");
|
|
145
|
+
String lastModified = response.header("Last-Modified");
|
|
146
|
+
if (etag == null && previousMeta != null) {
|
|
147
|
+
etag = previousMeta.optString("etag", null);
|
|
148
|
+
}
|
|
149
|
+
if (lastModified == null && previousMeta != null) {
|
|
150
|
+
lastModified = previousMeta.optString("lastModified", null);
|
|
151
|
+
}
|
|
152
|
+
if (etag != null) {
|
|
153
|
+
meta.put("etag", etag);
|
|
154
|
+
}
|
|
155
|
+
if (lastModified != null) {
|
|
156
|
+
meta.put("lastModified", lastModified);
|
|
157
|
+
}
|
|
158
|
+
if (total > 0) {
|
|
159
|
+
meta.put("total", total);
|
|
160
|
+
}
|
|
161
|
+
UpdateFileUtils.ensureParentDirectory(sidecar);
|
|
162
|
+
try (java.io.FileOutputStream out = new java.io.FileOutputStream(sidecar)) {
|
|
163
|
+
out.write(meta.toString().getBytes(StandardCharsets.UTF_8));
|
|
164
|
+
}
|
|
165
|
+
} catch (Throwable e) {
|
|
166
|
+
// Non-fatal: without a sidecar the next attempt starts from zero.
|
|
167
|
+
Log.w(UpdateContext.TAG, "Failed to persist resume sidecar: " + e);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// "bytes <start>-<end>/<total>". Returns the total (0 when "*"), or -1
|
|
172
|
+
// when the header is missing/malformed or the start does not match the
|
|
173
|
+
// local partial — either way the appended bytes could not be trusted.
|
|
174
|
+
private static long parseContentRange(String header, long expectedStart) {
|
|
175
|
+
if (header == null || !header.startsWith("bytes ")) {
|
|
176
|
+
return -1;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
String range = header.substring("bytes ".length()).trim();
|
|
180
|
+
int slash = range.indexOf('/');
|
|
181
|
+
int dash = range.indexOf('-');
|
|
182
|
+
if (slash < 0 || dash < 0 || dash > slash) {
|
|
183
|
+
return -1;
|
|
184
|
+
}
|
|
185
|
+
long start = Long.parseLong(range.substring(0, dash).trim());
|
|
186
|
+
if (start != expectedStart) {
|
|
187
|
+
return -1;
|
|
188
|
+
}
|
|
189
|
+
String totalPart = range.substring(slash + 1).trim();
|
|
190
|
+
return totalPart.equals("*") ? 0 : Long.parseLong(totalPart);
|
|
191
|
+
} catch (NumberFormatException e) {
|
|
192
|
+
return -1;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
106
196
|
private void downloadFile() throws IOException {
|
|
107
197
|
this.hash = params.hash;
|
|
108
198
|
String url = params.url;
|
|
109
199
|
File writePath = params.targetFile;
|
|
110
|
-
|
|
200
|
+
File sidecar = resumeSidecarFile(writePath);
|
|
201
|
+
UpdateFileUtils.ensureParentDirectory(writePath);
|
|
202
|
+
|
|
203
|
+
// Cross-launch resume (NATIVE_CHECKUPDATE_DESIGN §11.4): a brick gets
|
|
204
|
+
// a few hundred milliseconds per launch plus a bounded crash-rescue
|
|
205
|
+
// window, so every partial byte must survive process death and count.
|
|
206
|
+
long resumeOffset = 0;
|
|
207
|
+
JSONObject resumeMeta = readResumeMeta(sidecar, url);
|
|
208
|
+
if (resumeMeta != null && writePath.isFile() && writePath.length() > 0) {
|
|
209
|
+
long knownTotal = resumeMeta.optLong("total", 0);
|
|
210
|
+
long size = writePath.length();
|
|
211
|
+
if (knownTotal > 0 && size == knownTotal) {
|
|
212
|
+
// Fully received in a previous attempt (the process died
|
|
213
|
+
// between download end and unzip): nothing left to transfer.
|
|
214
|
+
downloadPhaseCompleted = true;
|
|
215
|
+
postProgress(knownTotal, knownTotal);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (knownTotal <= 0 || size < knownTotal) {
|
|
219
|
+
resumeOffset = size;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (resumeOffset == 0) {
|
|
223
|
+
if (writePath.exists() && !writePath.delete()) {
|
|
224
|
+
throw new IOException("Failed to replace existing file: " + writePath);
|
|
225
|
+
}
|
|
226
|
+
deleteResumeSidecar(writePath);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!transferArchive(url, writePath, sidecar, resumeMeta, resumeOffset)) {
|
|
230
|
+
// The server rejected the range for a partial that no longer
|
|
231
|
+
// matches (416): drop it and retry once from zero.
|
|
232
|
+
if (writePath.exists() && !writePath.delete()) {
|
|
233
|
+
throw new IOException("Failed to replace existing file: " + writePath);
|
|
234
|
+
}
|
|
235
|
+
deleteResumeSidecar(writePath);
|
|
236
|
+
if (!transferArchive(url, writePath, sidecar, null, 0)) {
|
|
237
|
+
throw new IOException("Server rejected the download range for " + url);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
downloadPhaseCompleted = true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* One HTTP transfer, appending from resumeOffset when the server honours
|
|
245
|
+
* the range. Returns false only for the retryable stale-partial case
|
|
246
|
+
* (416 with a size mismatch); throws on every other failure.
|
|
247
|
+
*/
|
|
248
|
+
private boolean transferArchive(
|
|
249
|
+
String url, File writePath, File sidecar, JSONObject resumeMeta, long resumeOffset
|
|
250
|
+
) throws IOException {
|
|
251
|
+
Request.Builder builder = new Request.Builder().url(url);
|
|
252
|
+
if (resumeOffset > 0) {
|
|
253
|
+
// Only resume requests pin the encoding: Range offsets must
|
|
254
|
+
// address the same bytes that are on disk. Fresh downloads keep
|
|
255
|
+
// OkHttp's transparent gzip (it decompresses and strips the
|
|
256
|
+
// headers itself), matching the pre-resume behaviour for servers
|
|
257
|
+
// that compress regardless.
|
|
258
|
+
builder.header("Accept-Encoding", "identity");
|
|
259
|
+
builder.header("Range", "bytes=" + resumeOffset + "-");
|
|
260
|
+
String validator = null;
|
|
261
|
+
if (resumeMeta != null) {
|
|
262
|
+
validator = resumeMeta.optString("etag", null);
|
|
263
|
+
if (validator == null) {
|
|
264
|
+
validator = resumeMeta.optString("lastModified", null);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (validator != null) {
|
|
268
|
+
// With a validator the server falls back to a full 200 when
|
|
269
|
+
// the file changed instead of appending mismatched bytes.
|
|
270
|
+
builder.header("If-Range", validator);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
111
273
|
|
|
112
274
|
OkHttpClient requestClient = HTTP_CLIENT;
|
|
113
275
|
if (params.deadlineNanos > 0) {
|
|
@@ -124,12 +286,16 @@ class DownloadTask implements Runnable {
|
|
|
124
286
|
.build();
|
|
125
287
|
}
|
|
126
288
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
289
|
+
try (Response response = requestClient.newCall(builder.build()).execute()) {
|
|
290
|
+
if (response.code() == 416) {
|
|
291
|
+
long total = resumeMeta == null ? 0 : resumeMeta.optLong("total", 0);
|
|
292
|
+
if (total > 0 && writePath.length() == total) {
|
|
293
|
+
// The partial is actually the complete file.
|
|
294
|
+
postProgress(total, total);
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
133
299
|
if (!response.isSuccessful()) {
|
|
134
300
|
throw new IOException("Server error: " + response.code() + " " + response.message());
|
|
135
301
|
}
|
|
@@ -139,30 +305,75 @@ class DownloadTask implements Runnable {
|
|
|
139
305
|
throw new IOException("Empty response body for " + url);
|
|
140
306
|
}
|
|
141
307
|
|
|
308
|
+
String contentEncoding = response.header("Content-Encoding");
|
|
309
|
+
// Visible only when the server encoded the body itself (OkHttp
|
|
310
|
+
// strips the header when it transparently decompresses its own
|
|
311
|
+
// gzip). Byte offsets in an encoded representation do not match
|
|
312
|
+
// the decoded bytes on disk, so resume is off the table.
|
|
313
|
+
boolean encodedBody = contentEncoding != null
|
|
314
|
+
&& !contentEncoding.equalsIgnoreCase("identity");
|
|
315
|
+
boolean append = response.code() == 206 && resumeOffset > 0;
|
|
316
|
+
if (append && encodedBody) {
|
|
317
|
+
// The server ignored Accept-Encoding: identity on a range
|
|
318
|
+
// request; the appended bytes could not be trusted.
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
long baseOffset = append ? resumeOffset : 0;
|
|
142
322
|
long contentLength = body.contentLength();
|
|
323
|
+
long totalAll;
|
|
324
|
+
if (append) {
|
|
325
|
+
totalAll = parseContentRange(response.header("Content-Range"), resumeOffset);
|
|
326
|
+
if (totalAll < 0) {
|
|
327
|
+
// Malformed or mismatched Content-Range: treat like a
|
|
328
|
+
// stale partial (one clean retry from zero) instead of
|
|
329
|
+
// failing — a throw would keep the partial and hit the
|
|
330
|
+
// same wall on every future attempt.
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
totalAll = contentLength > 0 ? contentLength : 0;
|
|
335
|
+
}
|
|
336
|
+
if (!append) {
|
|
337
|
+
// Destroy the old bytes before the sidecar can vouch for
|
|
338
|
+
// them with the new validators (a crash between the two
|
|
339
|
+
// writes must never leave a sidecar describing stale bytes).
|
|
340
|
+
if (writePath.exists() && !writePath.delete()) {
|
|
341
|
+
throw new IOException("Failed to replace existing file: " + writePath);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (encodedBody) {
|
|
345
|
+
// No resume across an encoded transfer.
|
|
346
|
+
deleteResumeSidecar(writePath);
|
|
347
|
+
} else {
|
|
348
|
+
// Persist before streaming so a mid-stream crash can resume.
|
|
349
|
+
writeResumeMeta(sidecar, url, response, resumeMeta, totalAll);
|
|
350
|
+
}
|
|
351
|
+
|
|
143
352
|
long bytesRead;
|
|
144
353
|
long received = 0;
|
|
145
354
|
int currentPercentage = 0;
|
|
146
|
-
long lastPostedBytes =
|
|
355
|
+
long lastPostedBytes = baseOffset;
|
|
147
356
|
|
|
148
357
|
try (
|
|
149
358
|
BufferedSource source = body.source();
|
|
150
|
-
BufferedSink sink = Okio.buffer(
|
|
359
|
+
BufferedSink sink = Okio.buffer(
|
|
360
|
+
append ? Okio.appendingSink(writePath) : Okio.sink(writePath))
|
|
151
361
|
) {
|
|
152
362
|
while ((bytesRead = source.read(sink.buffer(), DOWNLOAD_CHUNK_SIZE)) != -1) {
|
|
153
363
|
received += bytesRead;
|
|
154
364
|
sink.emit();
|
|
155
365
|
|
|
156
|
-
|
|
157
|
-
|
|
366
|
+
long overall = baseOffset + received;
|
|
367
|
+
if (totalAll > 0) {
|
|
368
|
+
int percentage = (int) (overall * 100.0 / totalAll + 0.5);
|
|
158
369
|
if (percentage > currentPercentage) {
|
|
159
370
|
currentPercentage = percentage;
|
|
160
|
-
lastPostedBytes =
|
|
161
|
-
postProgress(
|
|
371
|
+
lastPostedBytes = overall;
|
|
372
|
+
postProgress(overall, totalAll);
|
|
162
373
|
}
|
|
163
|
-
} else if (
|
|
164
|
-
lastPostedBytes =
|
|
165
|
-
postProgress(
|
|
374
|
+
} else if (overall - lastPostedBytes >= PROGRESS_BYTES_THRESHOLD) {
|
|
375
|
+
lastPostedBytes = overall;
|
|
376
|
+
postProgress(overall, totalAll);
|
|
166
377
|
}
|
|
167
378
|
}
|
|
168
379
|
sink.flush();
|
|
@@ -171,14 +382,17 @@ class DownloadTask implements Runnable {
|
|
|
171
382
|
if (contentLength >= 0 && received != contentLength) {
|
|
172
383
|
throw new IOException("Unexpected eof while reading downloaded update");
|
|
173
384
|
}
|
|
385
|
+
if (totalAll > 0 && writePath.length() != totalAll) {
|
|
386
|
+
throw new IOException("Download incomplete: expected " + totalAll
|
|
387
|
+
+ " bytes, got " + writePath.length());
|
|
388
|
+
}
|
|
174
389
|
// Final progress event, skipped when the loop already posted this
|
|
175
390
|
// exact value (known length reaching 100% posts it in-loop).
|
|
176
|
-
if (received != lastPostedBytes) {
|
|
177
|
-
postProgress(received,
|
|
391
|
+
if (baseOffset + received != lastPostedBytes) {
|
|
392
|
+
postProgress(baseOffset + received, totalAll);
|
|
178
393
|
}
|
|
179
394
|
}
|
|
180
|
-
|
|
181
|
-
downloadPhaseCompleted = true;
|
|
395
|
+
return true;
|
|
182
396
|
}
|
|
183
397
|
|
|
184
398
|
private byte[] readBytes(InputStream input) throws IOException {
|
|
@@ -312,9 +526,17 @@ class DownloadTask implements Runnable {
|
|
|
312
526
|
}
|
|
313
527
|
}
|
|
314
528
|
|
|
529
|
+
deleteConsumedArchive();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// The archive and its resume sidecar live and die together: once the
|
|
533
|
+
// archive is consumed (or classified as poisoned) the sidecar must not
|
|
534
|
+
// survive to vouch for a file that no longer exists or cannot be trusted.
|
|
535
|
+
private void deleteConsumedArchive() {
|
|
315
536
|
if (params.targetFile.exists()) {
|
|
316
537
|
params.targetFile.delete();
|
|
317
538
|
}
|
|
539
|
+
deleteResumeSidecar(params.targetFile);
|
|
318
540
|
}
|
|
319
541
|
|
|
320
542
|
private void doPatchFromApk() throws IOException, JSONException {
|
|
@@ -358,9 +580,7 @@ class DownloadTask implements Runnable {
|
|
|
358
580
|
}
|
|
359
581
|
|
|
360
582
|
bundledResourceCopier.copyFromResource(copyList, contents.copyCrcs);
|
|
361
|
-
|
|
362
|
-
params.targetFile.delete();
|
|
363
|
-
}
|
|
583
|
+
deleteConsumedArchive();
|
|
364
584
|
}
|
|
365
585
|
|
|
366
586
|
private void doPatchFromPpk() throws IOException, JSONException {
|
|
@@ -388,9 +608,7 @@ class DownloadTask implements Runnable {
|
|
|
388
608
|
contents.deletes.toArray(new String[0]),
|
|
389
609
|
contents.hbcTransformMetaFor("index.bundlejs.patch")
|
|
390
610
|
);
|
|
391
|
-
|
|
392
|
-
params.targetFile.delete();
|
|
393
|
-
}
|
|
611
|
+
deleteConsumedArchive();
|
|
394
612
|
}
|
|
395
613
|
|
|
396
614
|
private void doCleanUp() {
|
|
@@ -412,6 +630,13 @@ class DownloadTask implements Runnable {
|
|
|
412
630
|
} catch (IOException ioException) {
|
|
413
631
|
Log.e(UpdateContext.TAG, "Failed to clean patched directory", ioException);
|
|
414
632
|
}
|
|
633
|
+
if (downloadPhaseCompleted) {
|
|
634
|
+
// Fully received but failed to unzip/patch: the archive is
|
|
635
|
+
// poisoned, and resuming it would fail the same way on
|
|
636
|
+
// every future attempt. A download-phase failure keeps the
|
|
637
|
+
// partial + sidecar instead — that is the resume state.
|
|
638
|
+
deleteConsumedArchive();
|
|
639
|
+
}
|
|
415
640
|
break;
|
|
416
641
|
case DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD:
|
|
417
642
|
if (
|
|
@@ -421,6 +646,7 @@ class DownloadTask implements Runnable {
|
|
|
421
646
|
) {
|
|
422
647
|
Log.w(UpdateContext.TAG, "Failed to clean partial download " + params.targetFile);
|
|
423
648
|
}
|
|
649
|
+
deleteResumeSidecar(params.targetFile);
|
|
424
650
|
break;
|
|
425
651
|
default:
|
|
426
652
|
break;
|
|
@@ -498,6 +724,13 @@ class DownloadTask implements Runnable {
|
|
|
498
724
|
return;
|
|
499
725
|
}
|
|
500
726
|
|
|
727
|
+
if (taskType == DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD) {
|
|
728
|
+
// A plain download's file is final at completion — a surviving
|
|
729
|
+
// sidecar would make a later download of the same URL return
|
|
730
|
+
// these bytes without ever asking the server again.
|
|
731
|
+
deleteResumeSidecar(params.targetFile);
|
|
732
|
+
}
|
|
733
|
+
|
|
501
734
|
if (isPatchTask(taskType) && !alreadyCompleted) {
|
|
502
735
|
try {
|
|
503
736
|
File marker = new File(
|