-
Notifications
You must be signed in to change notification settings - Fork 282
Expand file tree
/
Copy pathNativeCheckOrchestrator.java
More file actions
809 lines (774 loc) Β· 34.5 KB
/
Copy pathNativeCheckOrchestrator.java
File metadata and controls
809 lines (774 loc) Β· 34.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
package cn.reactnative.modules.update;
import android.os.Build;
import android.util.Log;
import androidx.annotation.Nullable;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.BufferedSource;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
/**
* Native cold-start update check (NATIVE_CHECKUPDATE_DESIGN Β§10): once per
* process, a few seconds after getBundleUrl, entirely independent of the app
* bundle β this is what lets a device bricked by a bad hot update pull the
* fixed version on the next launch. All decisions come from
* cpp/update_flow_core via NativeUpdateFlow; this class is IO glue only.
* Failures are silent and bounded: one round per launch, no retry storms, no
* version blacklisting.
*/
final class NativeCheckOrchestrator {
static final String KEY_CONFIG = "nativeConfig";
// Raw response cache for the JS side to reuse (Β§10.3), scoped to the
// exact logical request and native config that produced it.
static final String KEY_RESP_CACHE = "nativeCheckResp";
// Set when a round starts, cleared when it ends (Β§11.4). Residue on the
// next launch means the previous process died mid-round (a crash rescue
// was truncated): that launch resumes immediately instead of waiting 5s.
static final String KEY_ROUND_INCOMPLETE = "nativeCheckIncomplete";
private static final int MAX_CHECK_HTTP_ATTEMPTS = 8;
private static final long DOWNLOAD_PHASE_TIMEOUT_SECONDS = 600;
// The check response (and a remote queryUrls list) is a small JSON
// document; anything bigger is a broken or hijacked endpoint.
private static final long MAX_CHECK_RESPONSE_BYTES = 1024 * 1024;
private static final AtomicBoolean scheduled = new AtomicBoolean(false);
// One round per process, whoever starts it first β the delayed cold-start
// thread or the crash-rescue thread (Β§11.3). roundDone lets the rescue
// wait for an in-flight round instead of racing it.
private static final AtomicBoolean roundStarted = new AtomicBoolean(false);
private static final CountDownLatch roundDone = new CountDownLatch(1);
private static volatile boolean roundCompleted = false;
// Flipped the moment a crash is being held. JS is dead from that point
// on, so there is no second decision maker: the round force-activates
// whatever it downloads (Β§11.3).
private static volatile boolean crashRescueActive = false;
// A version this process downloaded but left for JS to activate. If the
// process then crashes, JS will never activate it β the crash handler
// activates it directly (bounded local work, no network). The generation
// is the one the round committed under: a reset that lands afterwards
// bumps it, and the late activation must lose to that reset exactly like
// the round itself would. Written generation-first, read hash-first, so
// a torn read can only see a stricter (newer) generation.
private static volatile String unactivatedHash;
private static volatile long unactivatedGeneration;
private static volatile UpdateContext sContext;
private static volatile String sLaunchRolledBackVersion;
// Config JSON for which JS reported a completed check in this process
// (markJsCheckCompleted). Process-scoped by design: the next launch
// starts with no signal and the cold-start round runs again.
private static volatile String sJsCompletedConfig;
// Published after the launch rollback snapshot, before host calls are accepted.
private static volatile boolean nativeReady;
private static volatile NativeUpdateResult roundResult =
NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed");
private static volatile long roundGeneration = -1;
private static volatile long roundConfigGeneration = -1;
private static volatile String roundConfigJson;
/** Blocking only on the host API's worker; never call on the UI thread. */
static NativeUpdateResult checkAndUpdate(UpdateContext context) throws InterruptedException {
if (UpdateContext.DEBUG) {
return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "debug");
}
if (!nativeReady || sContext != context || !context.getIsUsingBundleUrl()) {
return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_initialized");
}
String configJson = context.getKv(KEY_CONFIG);
if (configJson == null || configJson.isEmpty()) {
return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_configured");
}
try {
JSONObject config = new JSONObject(configJson);
if (config.optBoolean("disabled", false)) {
return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "disabled");
}
if (config.optString("appKey", "").isEmpty()) {
return NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config");
}
} catch (JSONException e) {
return NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config");
}
startRound(0);
if (!roundStarted.get()) {
return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "config_changed");
}
roundDone.await();
if (roundConfigGeneration != UpdateContext.getNativeConfigGeneration()
|| !configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) {
return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "config_changed");
}
if (roundGeneration != UpdateContext.getResetGeneration()) {
return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset");
}
return roundResult;
}
static void markJsCheckCompleted(String config) {
sJsCompletedConfig = config;
}
/**
* True when JS already obtained a valid response in this process for the
* exact config the native round would use: the delayed round is then a
* duplicate request. Only the scheduled round consults this β the
* crash-rescue path still runs, JS is dead by then.
*/
private static boolean isJsCheckCompleted(UpdateContext context) {
String jsConfig = sJsCompletedConfig;
return jsConfig != null && jsConfig.equals(context.getKv(KEY_CONFIG));
}
private NativeCheckOrchestrator() {
}
static void schedule(final UpdateContext context, final String launchRolledBackVersion) {
if (UpdateContext.DEBUG) {
return;
}
if (!scheduled.compareAndSet(false, true)) {
return;
}
sContext = context;
sLaunchRolledBackVersion = launchRolledBackVersion;
nativeReady = true;
// The crash-hold rescue shares the orchestrator's rollout gate: no
// persisted config, no handler (Β§11.3).
if (context.getKv(KEY_CONFIG) != null) {
CrashRescue.install();
}
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
// Keep the check away from the cold-start critical path
// (Β§7 R5) β unless the previous process died mid-round,
// in which case every launch second counts (Β§11.4).
if (context.getKv(KEY_ROUND_INCOMPLETE) == null) {
Thread.sleep(5000);
}
if (isJsCheckCompleted(context)) {
// Not consuming the round: a later crash rescue may
// still need it.
Log.i(UpdateContext.TAG,
"native check skipped: JS check completed in this process");
return;
}
startRound(0);
} catch (Throwable e) {
// The rescue path must never take the app down with it.
Log.w(UpdateContext.TAG, "native check failed: " + e);
}
}
}, "pushy-native-check");
thread.setPriority(Thread.MIN_PRIORITY + 1);
thread.setDaemon(true);
thread.start();
}
static boolean isRoundInFlight() {
return roundStarted.get() && !roundCompleted;
}
/**
* Runs the process's single round on the calling thread if nobody has
* started it yet. deadlineNanos > 0 (crash rescue) caps every HTTP call
* and download phase to the remaining budget.
*/
private static boolean hasRunnableConfig(UpdateContext context) {
if (context == null) {
return false;
}
try {
String json = context.getKv(KEY_CONFIG);
if (json == null) {
return false;
}
JSONObject config = new JSONObject(json);
return !config.optBoolean("disabled", false)
&& config.opt("appKey") instanceof String
&& !config.getString("appKey").trim().isEmpty();
} catch (JSONException e) {
return false;
}
}
static void onConfigured(UpdateContext context) {
if (nativeReady && sContext == context && hasRunnableConfig(context)) {
CrashRescue.install();
}
}
private static void startRound(long deadlineNanos) {
// An automatic check before first-run provisioning must not consume
// the process's only round. Hosts may configure later in this launch.
if (!hasRunnableConfig(sContext)) {
return;
}
if (!roundStarted.compareAndSet(false, true)) {
return;
}
try {
runOnce(sContext, sLaunchRolledBackVersion, deadlineNanos);
} catch (Throwable e) {
Log.w(UpdateContext.TAG, "native check failed: " + e);
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "internal_error");
} finally {
roundCompleted = true;
roundDone.countDown();
}
}
/**
* Crash-rescue entry (Β§11.3), called from the handler's worker thread
* while the uncaught-exception handler holds the dying process. Ensures
* this process's round runs to completion within the budget, then
* activates a downloaded-but-unactivated version if one exists β the
* last chance before the process is gone.
*/
static void runRescue(long deadlineNanos) {
UpdateContext context = sContext;
if (context == null) {
return;
}
crashRescueActive = true;
startRound(deadlineNanos);
if (roundStarted.get() && !roundCompleted) {
long remainingNanos = deadlineNanos - System.nanoTime();
if (remainingNanos > 0) {
try {
roundDone.await(remainingNanos, TimeUnit.NANOSECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
activatePendingVersion(context);
}
// The alert-strategy variant of the Β§10.7 hole: the round downloaded a
// fix but deferred activation to JS, and JS is now dead. Activation is
// local and bounded (the bundle digest, then a state switch under the
// commit lock).
private static void activatePendingVersion(UpdateContext context) {
String hash = unactivatedHash;
if (hash == null) {
return;
}
String hashInfoJson = null;
String existingInfo = context.getKv("hash_" + hash);
if (existingInfo != null) {
try {
JSONObject info = new JSONObject(existingInfo);
info.put("crashRescue", true);
hashInfoJson = info.toString();
} catch (JSONException ignored) {
}
}
try {
if (context.commitNativeCheckResult(
unactivatedGeneration, hash, hashInfoJson, true, null)) {
unactivatedHash = null;
Log.i(UpdateContext.TAG,
"crash rescue: activated downloaded version " + hash);
} else {
Log.i(UpdateContext.TAG,
"crash rescue: reset since download, dropping activation");
}
} catch (Exception e) {
Log.w(UpdateContext.TAG, "crash rescue: activation failed: " + e);
}
}
private static void runOnce(
UpdateContext context,
String launchRolledBackVersion,
long deadlineNanos
) throws JSONException {
final long resetGeneration = UpdateContext.getResetGeneration();
roundGeneration = resetGeneration;
roundConfigGeneration = UpdateContext.getNativeConfigGeneration();
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed");
String configJson = context.getKv(KEY_CONFIG);
roundConfigJson = configJson;
if (configJson == null || configJson.isEmpty()) {
roundResult = NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_configured");
return;
}
JSONObject config;
try {
config = new JSONObject(configJson);
} catch (JSONException e) {
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config");
return;
}
if (config.optBoolean("disabled", false)) {
roundResult = NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "disabled");
return;
}
String appKey = config.optString("appKey", "");
if (appKey.isEmpty()) {
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config");
return;
}
// Keep the existing interrupted-round breadcrumb and reset generation.
try {
context.setKv(KEY_ROUND_INCOMPLETE, "1");
} catch (IllegalStateException ignored) {
}
try {
runConfiguredRound(
context, launchRolledBackVersion, deadlineNanos,
resetGeneration, configJson, config, appKey);
} finally {
try {
context.removeKv(KEY_ROUND_INCOMPLETE);
} catch (IllegalStateException ignored) {
}
}
}
private static void runConfiguredRound(
UpdateContext context,
String launchRolledBackVersion,
long deadlineNanos,
long resetGeneration,
String configJson,
JSONObject config,
String appKey
) throws JSONException {
String packageVersion = config.optString(
"packageVersion", context.getPackageVersion());
if (packageVersion.isEmpty()) {
packageVersion = context.getPackageVersion();
}
String currentVersion = context.getCurrentVersion();
// Snapshot captured on the launch path before getConstants consumes
// the one-shot rollback marker. Reading SharedPreferences here, five
// seconds later, would lose the guard and could forceBoot the version
// that this very launch just rolled back.
String rolledBackVersion = launchRolledBackVersion;
String uuid = context.getKv("uuid");
if (uuid == null) {
uuid = "";
}
JSONObject identity = new JSONObject();
identity.put("packageVersion", packageVersion);
identity.put(
"currentVersion",
currentVersion == null ? JSONObject.NULL : currentVersion
);
identity.put("uuid", uuid);
if (rolledBackVersion != null) {
identity.put("rolledBackVersion", rolledBackVersion);
}
JSONObject cInfo = new JSONObject();
cInfo.put("rnu", config.optString("rnu", ""));
cInfo.put("rn", config.optString("rn", ""));
// React Native's Platform.Version is the Android SDK integer; use the
// same value so this request can be fingerprinted against the JS one.
cInfo.put("os", "android " + Build.VERSION.SDK_INT);
cInfo.put("uuid", uuid);
JSONObject input = new JSONObject();
input.put("packageVersion", packageVersion);
input.put(
"currentVersion",
currentVersion == null ? JSONObject.NULL : currentVersion
);
input.put("buildTime", context.getBuildTime());
input.put("cInfo", cInfo);
input.put("supportedDiffVersion", NativeUpdateCore.supportedDiffVersion());
input.put("bundleHash", context.computeBundleHash());
String body = NativeUpdateFlow.buildCheckRequestBody(input.toString());
if (body == null) {
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_request");
return;
}
String responseText = runCheckRequest(config, appKey, body, deadlineNanos);
if (responseText == null) {
Log.i(UpdateContext.TAG,
"native check: no endpoint reachable, giving up until next launch");
return;
}
// Cache freshness is anchored to when the server response arrived,
// not to when a potentially long download/patch/activation finished.
final long responseAtSeconds = System.currentTimeMillis() / 1000;
String decisionJson = NativeUpdateFlow.handleCheckResponse(
responseText, identity.toString(), config.optString("afterDownload", ""));
if (decisionJson == null) {
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_response");
return;
}
JSONObject decision = new JSONObject(decisionJson);
if (!"download".equals(decision.optString("action"))) {
boolean committed = context.commitNativeCheckResult(
resetGeneration, null, null, false,
buildResponseCacheJson(configJson, body, responseText, responseAtSeconds));
roundResult = committed
? NativeUpdateResult.of(NativeUpdateResult.NO_UPDATE, decision.optString("reason"))
: NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset");
Log.i(UpdateContext.TAG,
"native check: nothing to do (" + decision.optString("reason") + ")");
return;
}
String hash = decision.optString("hash", "");
if (!UpdateFileUtils.isSafePathComponent(hash)) {
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_response");
return;
}
boolean downloaded = context.hasCompletedVersion(hash);
if (!downloaded) {
downloaded = performAttempts(
context, decision.optJSONArray("attempts"), hash, currentVersion,
deadlineNanos);
}
if (!downloaded) {
// The native attempt has finished, so JS may safely reuse the
// response and retry through its own strategy chain.
boolean committed = context.commitNativeCheckResult(
resetGeneration, null, null, false,
buildResponseCacheJson(configJson, body, responseText, responseAtSeconds));
roundResult = NativeUpdateResult.of(
committed ? NativeUpdateResult.FAILED : NativeUpdateResult.CANCELLED,
committed ? "download_failed" : "reset");
return;
}
// Version info (mirroring the JS side's setLocalHashInfo), the
// activation and the response cache all land in one atomic commit β
// see UpdateContext.commitNativeCheckResult.
String hashInfoJson = null;
JSONObject info = decision.optJSONObject("info");
if (info != null) {
JSONObject hashInfo = new JSONObject();
for (String key : new String[] {"name", "description", "metaInfo"}) {
Object value = info.opt(key);
if (value instanceof String) {
hashInfo.put(key, value);
}
}
// A forceBoot activation is the brick-rescue path: mark it in the
// persisted info so JS can report force_boot_rescue when this
// version survives to markSuccess. Only the server-sent directive
// counts β a silent-strategy activation is ordinary delivery.
JSONObject infoConfig = info.optJSONObject("config");
if (infoConfig != null && infoConfig.optBoolean("forceBoot", false)) {
hashInfo.put("forceBootRescue", true);
}
if (crashRescueActive) {
hashInfo.put("crashRescue", true);
}
hashInfoJson = hashInfo.toString();
}
// Silent strategies or a server-marked forceBoot version (per-version
// remote override β the brick rescue) activate for the next launch;
// otherwise activation stays with the JS side. Unless a crash is
// being held: JS is dead, deferring to it would leave the fix on
// disk forever (Β§11.3).
boolean activate = decision.optBoolean("activate", false) || crashRescueActive;
boolean committed;
try {
committed = context.commitNativeCheckResult(
resetGeneration,
hash,
hashInfoJson,
activate,
buildResponseCacheJson(configJson, body, responseText, responseAtSeconds));
} catch (Exception e) {
Log.w(UpdateContext.TAG, "native check: commit failed: " + e);
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "commit_failed");
return;
}
if (!committed) {
Log.i(UpdateContext.TAG, "native check: reset during round, dropping result");
} else if (activate) {
unactivatedHash = null;
Log.i(UpdateContext.TAG,
"native check: downloaded " + hash + " and set for next launch");
} else {
// Remembered so a crash later in this process can still activate
// it (activatePendingVersion) β JS never will.
unactivatedGeneration = resetGeneration;
unactivatedHash = hash;
Log.i(UpdateContext.TAG,
"native check: downloaded " + hash + ", activation left to JS");
}
roundResult = committed
? NativeUpdateResult.downloaded(hash, activate)
: NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset");
}
private static String buildResponseCacheJson(
String configJson,
String requestBody,
String responseText,
long responseAtSeconds
) throws JSONException {
JSONObject cacheEntry = new JSONObject();
cacheEntry.put("ts", responseAtSeconds);
cacheEntry.put("body", responseText);
cacheEntry.put("request", requestBody);
cacheEntry.put("config", configJson);
return cacheEntry.toString();
}
private static final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.callTimeout(15, TimeUnit.SECONDS)
.build();
private static String httpRequest(String url, String postBody, long deadlineNanos) {
try {
Request.Builder builder =
new Request.Builder().url(url).header("Accept", "application/json");
if (postBody != null) {
builder.post(RequestBody.create(
postBody, MediaType.parse("application/json; charset=utf-8")));
}
OkHttpClient client = httpClient;
if (deadlineNanos > 0) {
// Crash-rescue budget: never let a single request outlive the
// handler's hold window.
long remainingMillis = TimeUnit.NANOSECONDS.toMillis(
deadlineNanos - System.nanoTime());
if (remainingMillis <= 0) {
return null;
}
if (remainingMillis < 15000) {
client = httpClient.newBuilder()
.callTimeout(remainingMillis, TimeUnit.MILLISECONDS)
.build();
}
}
try (Response response = client.newCall(builder.build()).execute()) {
// Same rule as the artifact download: an https endpoint that
// redirects to plaintext http is a failed endpoint.
DownloadTask.rejectProtocolDowngrade(url, response);
ResponseBody body = response.body();
if (!response.isSuccessful() || body == null) {
return null;
}
return readBoundedBody(body);
}
} catch (Exception e) {
// One line per failed endpoint; the fallback chain is otherwise
// invisible in the field.
Log.w(UpdateContext.TAG, "native check: request failed with "
+ e.getClass().getName() + ": " + e.getMessage());
return null;
}
}
/**
* Reads the body with a hard cap (CODE_AUDIT 2.7): a hijacked endpoint
* must not be able to OOM this thread with an unbounded string. Over the
* cap counts as a failed endpoint (null), like any other bad response.
*/
@Nullable
private static String readBoundedBody(ResponseBody body) throws IOException {
if (body.contentLength() > MAX_CHECK_RESPONSE_BYTES) {
return null;
}
BufferedSource source = body.source();
if (source.request(MAX_CHECK_RESPONSE_BYTES + 1)) {
Log.w(UpdateContext.TAG, "native check: response exceeds "
+ MAX_CHECK_RESPONSE_BYTES + " bytes, ignoring endpoint");
return null;
}
MediaType contentType = body.contentType();
Charset charset = contentType == null
? null : contentType.charset(StandardCharsets.UTF_8);
return source.readString(charset == null ? StandardCharsets.UTF_8 : charset);
}
// Shared schema rule (update_flow_core::IsValidCheckResponse): a 200 with
// `{"error": ...}` is a failed endpoint, not a verdict, and must not stop
// the endpoint fallback.
private static boolean isValidCheckResponse(String responseText) {
return responseText != null && NativeUpdateFlow.isValidCheckResponse(responseText);
}
/**
* Sequential fallback over the ordered candidates (Β§5.1): one request at
* a time with its own timeout; after the configured round fails,
* queryUrls discovery merges remote candidates (excluding the
* already-tried) for one more round. No hedged race on purpose β this
* path is latency-insensitive.
*/
private static String runCheckRequest(
JSONObject config, String appKey, String body, long deadlineNanos
) {
JSONArray endpoints = config.optJSONArray("endpoints");
String orderedJson = NativeUpdateFlow.orderEndpointCandidates(
endpoints == null ? "[]" : endpoints.toString(), Math.random());
JSONArray ordered;
try {
ordered = orderedJson == null ? new JSONArray() : new JSONArray(orderedJson);
} catch (JSONException e) {
return null;
}
HashSet<String> tried = new HashSet<>();
int httpAttempts = 0;
for (int i = 0; i < ordered.length(); i++) {
String base = HttpUtils.normalizeEndpointBase(ordered.optString(i, ""));
if (base.isEmpty() || !tried.add(base)) {
continue;
}
if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) {
return null;
}
String response = httpRequest(
base + "/checkUpdate/" + appKey, body, deadlineNanos);
if (isValidCheckResponse(response)) {
return response;
}
}
JSONArray queryUrls = config.optJSONArray("queryUrls");
if (queryUrls == null) {
return null;
}
for (int i = 0; i < queryUrls.length(); i++) {
String listUrl = queryUrls.optString(i, "");
if (listUrl.isEmpty()) {
continue;
}
if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) {
return null;
}
String listText = httpRequest(listUrl, null, deadlineNanos);
if (listText == null) {
continue;
}
JSONArray remote;
try {
remote = new JSONArray(listText);
} catch (JSONException e) {
continue;
}
for (int j = 0; j < remote.length(); j++) {
String base = HttpUtils.normalizeEndpointBase(remote.optString(j, ""));
if (base.isEmpty() || tried.contains(base)) {
continue;
}
if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) {
return null;
}
tried.add(base);
String response = httpRequest(
base + "/checkUpdate/" + appKey, body, deadlineNanos);
if (isValidCheckResponse(response)) {
return response;
}
}
// One successfully fetched remote list is enough.
break;
}
return null;
}
private static long capToRescueBudget(long phaseDeadlineNanos, long rescueDeadlineNanos) {
if (rescueDeadlineNanos <= 0) {
return phaseDeadlineNanos;
}
return Math.min(phaseDeadlineNanos, rescueDeadlineNanos);
}
private static boolean performAttempts(
UpdateContext context, JSONArray attempts, String hash, String originHash,
long rescueDeadlineNanos
) {
if (attempts == null) {
return false;
}
final long incrementalDeadlineNanos = capToRescueBudget(
System.nanoTime() + TimeUnit.SECONDS.toNanos(DOWNLOAD_PHASE_TIMEOUT_SECONDS),
rescueDeadlineNanos);
long fullDeadlineNanos = 0;
for (int i = 0; i < attempts.length(); i++) {
JSONObject attempt = attempts.optJSONObject(i);
if (attempt == null) {
continue;
}
String type = attempt.optString("type");
if ("diff".equals(type) && (originHash == null || originHash.isEmpty())) {
// diff patches from the running version; none is running.
continue;
}
final boolean isFullAttempt = !"diff".equals(type) && !"pdiff".equals(type);
if (isFullAttempt && fullDeadlineNanos == 0) {
// Incremental failures must not consume the last-resort full
// download's budget. Each phase gets one bounded 10min window.
fullDeadlineNanos = capToRescueBudget(
System.nanoTime()
+ TimeUnit.SECONDS.toNanos(DOWNLOAD_PHASE_TIMEOUT_SECONDS),
rescueDeadlineNanos);
}
final long deadlineNanos = isFullAttempt
? fullDeadlineNanos : incrementalDeadlineNanos;
JSONArray urls = attempt.optJSONArray("urls");
if (urls == null) {
continue;
}
for (int j = 0; j < urls.length(); j++) {
String url = urls.optString(j, "");
if (url.isEmpty()) {
continue;
}
// Check before enqueueing: once the phase budget is gone we
// must not launch an orphan download that outlives the round.
long remainingNanos = deadlineNanos - System.nanoTime();
if (remainingNanos <= 0) {
if (isFullAttempt) {
return false;
}
break;
}
final CountDownLatch latch = new CountDownLatch(1);
final AtomicBoolean succeeded = new AtomicBoolean(false);
final String attemptType = type;
UpdateContext.DownloadFileListener listener =
new UpdateContext.DownloadFileListener() {
@Override
public void onDownloadCompleted(DownloadTaskParams params) {
succeeded.set(true);
latch.countDown();
}
@Override
public void onDownloadFailed(Throwable error) {
Log.i(UpdateContext.TAG, "native check: " + attemptType
+ " attempt failed: " + error);
latch.countDown();
}
};
DownloadTaskParams task;
if ("diff".equals(type)) {
task = context.downloadPatchFromPpk(
url, hash, originHash, listener, deadlineNanos);
} else if ("pdiff".equals(type)) {
task = context.downloadPatchFromApk(
url, hash, listener, deadlineNanos);
} else {
task = context.downloadFullUpdate(
url, hash, listener, deadlineNanos);
}
try {
if (!latch.await(remainingNanos, TimeUnit.NANOSECONDS)) {
Log.w(UpdateContext.TAG,
"native check: download phase timed out during " + type);
// The task shares one download thread with the next
// attempt: cancel its transfer (or keep it from
// starting) instead of queueing behind it
// (CODE_AUDIT 2.12).
if (task != null) {
task.cancel();
}
if (isFullAttempt) {
return false;
}
break;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
if (task != null) {
task.cancel();
}
return false;
}
if (succeeded.get()) {
return true;
}
}
}
return false;
}
}