react-native-update 10.51.1 → 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.
@@ -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
- Request request = new Request.Builder().url(url).build();
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
- UpdateFileUtils.ensureParentDirectory(writePath);
128
- if (writePath.exists() && !writePath.delete()) {
129
- throw new IOException("Failed to replace existing file: " + writePath);
130
- }
131
-
132
- try (Response response = requestClient.newCall(request).execute()) {
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 = 0;
355
+ long lastPostedBytes = baseOffset;
147
356
 
148
357
  try (
149
358
  BufferedSource source = body.source();
150
- BufferedSink sink = Okio.buffer(Okio.sink(writePath))
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
- if (contentLength > 0) {
157
- int percentage = (int) (received * 100.0 / contentLength + 0.5);
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 = received;
161
- postProgress(received, contentLength);
371
+ lastPostedBytes = overall;
372
+ postProgress(overall, totalAll);
162
373
  }
163
- } else if (received - lastPostedBytes >= PROGRESS_BYTES_THRESHOLD) {
164
- lastPostedBytes = received;
165
- postProgress(received, contentLength);
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, contentLength);
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
- if (params.targetFile.exists()) {
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
- if (params.targetFile.exists()) {
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(