react-native-update 10.49.0 → 10.51.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/BUNDLEHASH_MIGRATION.md +22 -2
- package/NATIVE_CHECKUPDATE_DESIGN.md +251 -21
- package/NATIVE_CHECK_FOLLOWUPS.md +365 -0
- package/README-CN.md +14 -1
- package/README.md +14 -1
- package/android/jni/Android.mk +5 -1
- package/android/lib/arm64-v8a/librnupdate.so +0 -0
- package/android/lib/armeabi-v7a/librnupdate.so +0 -0
- package/android/lib/x86/librnupdate.so +0 -0
- package/android/lib/x86_64/librnupdate.so +0 -0
- package/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +42 -9
- package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +98 -22
- package/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +3 -0
- package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +455 -0
- package/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java +23 -0
- package/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +16 -0
- package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +196 -42
- package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +56 -3
- package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
- package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
- package/cpp/patch_core/digest.cpp +53 -0
- package/cpp/patch_core/digest.h +18 -0
- package/cpp/patch_core/patch_core.cpp +17 -1
- package/cpp/patch_core/patch_core.h +8 -0
- package/cpp/patch_core/tests/patch_core_test.cpp +99 -0
- package/cpp/update_flow_core/flow_json.cpp +522 -0
- package/cpp/update_flow_core/flow_json.h +103 -0
- package/cpp/update_flow_core/tests/flow_vectors.json +1401 -0
- package/cpp/update_flow_core/tests/update_flow_core_test.cpp +317 -0
- package/cpp/update_flow_core/update_flow_core.cpp +301 -0
- package/cpp/update_flow_core/update_flow_core.h +77 -0
- package/cpp/update_flow_core/update_flow_jni.cpp +121 -0
- package/harmony/pushy/src/main/cpp/CMakeLists.txt +7 -0
- package/harmony/pushy/src/main/cpp/pushy.cpp +150 -1
- package/harmony/pushy/src/main/ets/DownloadTask.ts +133 -9
- package/harmony/pushy/src/main/ets/DownloadTaskParams.ts +3 -0
- package/harmony/pushy/src/main/ets/MonotonicClock.ts +13 -0
- package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +500 -0
- package/harmony/pushy/src/main/ets/NativePatchCore.ts +16 -0
- package/harmony/pushy/src/main/ets/PathUtils.ts +21 -0
- package/harmony/pushy/src/main/ets/PushyTurboModule.ts +22 -0
- package/harmony/pushy/src/main/ets/UpdateContext.ts +152 -52
- package/harmony/pushy.har +0 -0
- package/ios/RCTPushy/RCTPushy.mm +840 -68
- package/ios/RCTPushy/RCTPushyDownloader.h +1 -0
- package/ios/RCTPushy/RCTPushyDownloader.mm +4 -3
- package/package.json +3 -1
- package/react-native-update.podspec +2 -0
- package/src/NativePushy.ts +14 -0
- package/src/client.ts +439 -122
- package/src/endpoint.ts +105 -80
- package/src/error.ts +4 -0
- package/src/locales/en.ts +4 -0
- package/src/locales/zh.ts +4 -0
- package/src/provider.tsx +74 -9
- package/src/telemetry.ts +8 -0
- package/src/type.ts +31 -0
- package/src/updateFlowCore.ts +320 -0
- package/src/utils.ts +14 -12
- package/src/isInRollout.ts +0 -86
- package/src/resolveCheckResult.ts +0 -30
|
@@ -77,6 +77,9 @@ class DownloadTask implements Runnable {
|
|
|
77
77
|
private final byte[] buffer = new byte[DOWNLOAD_CHUNK_SIZE];
|
|
78
78
|
private final BundledResourceCopier bundledResourceCopier;
|
|
79
79
|
private String hash;
|
|
80
|
+
// Set once downloadFile() returns: failures after this point in a patch
|
|
81
|
+
// task are patch-application failures, not download failures.
|
|
82
|
+
private boolean downloadPhaseCompleted = false;
|
|
80
83
|
|
|
81
84
|
DownloadTask(Context context, DownloadTaskParams params) {
|
|
82
85
|
this.context = context.getApplicationContext();
|
|
@@ -104,14 +107,29 @@ class DownloadTask implements Runnable {
|
|
|
104
107
|
this.hash = params.hash;
|
|
105
108
|
String url = params.url;
|
|
106
109
|
File writePath = params.targetFile;
|
|
107
|
-
UpdateFileUtils.ensureParentDirectory(writePath);
|
|
108
110
|
Request request = new Request.Builder().url(url).build();
|
|
109
111
|
|
|
112
|
+
OkHttpClient requestClient = HTTP_CLIENT;
|
|
113
|
+
if (params.deadlineNanos > 0) {
|
|
114
|
+
long remainingNanos = params.deadlineNanos - System.nanoTime();
|
|
115
|
+
if (remainingNanos <= 0) {
|
|
116
|
+
throw new IOException("Download deadline expired before start");
|
|
117
|
+
}
|
|
118
|
+
long remainingMillis = Math.max(
|
|
119
|
+
1L,
|
|
120
|
+
java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(remainingNanos)
|
|
121
|
+
);
|
|
122
|
+
requestClient = HTTP_CLIENT.newBuilder()
|
|
123
|
+
.callTimeout(remainingMillis, java.util.concurrent.TimeUnit.MILLISECONDS)
|
|
124
|
+
.build();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
UpdateFileUtils.ensureParentDirectory(writePath);
|
|
110
128
|
if (writePath.exists() && !writePath.delete()) {
|
|
111
129
|
throw new IOException("Failed to replace existing file: " + writePath);
|
|
112
130
|
}
|
|
113
131
|
|
|
114
|
-
try (Response response =
|
|
132
|
+
try (Response response = requestClient.newCall(request).execute()) {
|
|
115
133
|
if (!response.isSuccessful()) {
|
|
116
134
|
throw new IOException("Server error: " + response.code() + " " + response.message());
|
|
117
135
|
}
|
|
@@ -160,6 +178,7 @@ class DownloadTask implements Runnable {
|
|
|
160
178
|
}
|
|
161
179
|
}
|
|
162
180
|
|
|
181
|
+
downloadPhaseCompleted = true;
|
|
163
182
|
}
|
|
164
183
|
|
|
165
184
|
private byte[] readBytes(InputStream input) throws IOException {
|
|
@@ -408,39 +427,96 @@ class DownloadTask implements Runnable {
|
|
|
408
427
|
}
|
|
409
428
|
}
|
|
410
429
|
|
|
430
|
+
private boolean isPatchTask(int taskType) {
|
|
431
|
+
return taskType == DownloadTaskParams.TASK_TYPE_PATCH_FULL
|
|
432
|
+
|| taskType == DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK
|
|
433
|
+
|| taskType == DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private boolean hasCompletedPatchDirectory() {
|
|
437
|
+
return params.unzipDirectory != null
|
|
438
|
+
&& new File(params.unzipDirectory, "index.bundlejs").isFile()
|
|
439
|
+
&& new File(
|
|
440
|
+
params.unzipDirectory,
|
|
441
|
+
UpdateContext.VERSION_COMPLETE_FILE
|
|
442
|
+
).isFile();
|
|
443
|
+
}
|
|
444
|
+
|
|
411
445
|
@Override
|
|
412
446
|
public void run() {
|
|
413
447
|
int taskType = params.type;
|
|
448
|
+
final boolean alreadyCompleted = isPatchTask(taskType)
|
|
449
|
+
&& hasCompletedPatchDirectory();
|
|
414
450
|
try {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
451
|
+
if (alreadyCompleted) {
|
|
452
|
+
Log.i(UpdateContext.TAG,
|
|
453
|
+
"download task: version " + params.hash + " already completed");
|
|
454
|
+
} else {
|
|
455
|
+
switch (taskType) {
|
|
456
|
+
case DownloadTaskParams.TASK_TYPE_PATCH_FULL:
|
|
457
|
+
doFullPatch();
|
|
458
|
+
break;
|
|
459
|
+
case DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK:
|
|
460
|
+
doPatchFromApk();
|
|
461
|
+
break;
|
|
462
|
+
case DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK:
|
|
463
|
+
doPatchFromPpk();
|
|
464
|
+
break;
|
|
465
|
+
case DownloadTaskParams.TASK_TYPE_CLEANUP:
|
|
466
|
+
doCleanUp();
|
|
467
|
+
break;
|
|
468
|
+
case DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD:
|
|
469
|
+
downloadFile();
|
|
470
|
+
break;
|
|
471
|
+
default:
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
433
474
|
}
|
|
434
475
|
} catch (Throwable error) {
|
|
435
476
|
Log.e(UpdateContext.TAG, "download task failed", error);
|
|
436
|
-
|
|
477
|
+
// A duplicate task must never delete a version completed by an
|
|
478
|
+
// earlier queued task. The marker + bundle pair is the ownership
|
|
479
|
+
// handoff: once present, this failure did not create that install.
|
|
480
|
+
if (!hasCompletedPatchDirectory()) {
|
|
481
|
+
cleanUpAfterFailure(taskType);
|
|
482
|
+
}
|
|
437
483
|
|
|
438
484
|
if (params.listener != null) {
|
|
439
|
-
|
|
485
|
+
// A patch task that failed after its artifact was fully
|
|
486
|
+
// downloaded (unzip / hdiff / resource copy, incl. copiesCrc
|
|
487
|
+
// verification) is a patch failure, not a download failure —
|
|
488
|
+
// the module maps the exception type to PATCH_FAILED.
|
|
489
|
+
Throwable classified = error;
|
|
490
|
+
if (downloadPhaseCompleted
|
|
491
|
+
&& isPatchTask(taskType)
|
|
492
|
+
&& !(error instanceof PatchFailedException)) {
|
|
493
|
+
classified = new PatchFailedException(
|
|
494
|
+
String.valueOf(error.getMessage()), error);
|
|
495
|
+
}
|
|
496
|
+
params.listener.onDownloadFailed(classified);
|
|
440
497
|
}
|
|
441
498
|
return;
|
|
442
499
|
}
|
|
443
500
|
|
|
501
|
+
if (isPatchTask(taskType) && !alreadyCompleted) {
|
|
502
|
+
try {
|
|
503
|
+
File marker = new File(
|
|
504
|
+
params.unzipDirectory,
|
|
505
|
+
UpdateContext.VERSION_COMPLETE_FILE
|
|
506
|
+
);
|
|
507
|
+
if (!marker.createNewFile() && !marker.isFile()) {
|
|
508
|
+
throw new IOException("Failed to mark completed update: " + marker);
|
|
509
|
+
}
|
|
510
|
+
} catch (Throwable error) {
|
|
511
|
+
Log.e(UpdateContext.TAG, "failed to mark completed update", error);
|
|
512
|
+
cleanUpAfterFailure(taskType);
|
|
513
|
+
if (params.listener != null) {
|
|
514
|
+
params.listener.onDownloadFailed(error);
|
|
515
|
+
}
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
444
520
|
// The task itself succeeded. Run the completion callback outside the
|
|
445
521
|
// try/catch above so an exception thrown by the callback (e.g. a
|
|
446
522
|
// FileProvider misconfiguration during installApk) is not mistaken for
|
|
@@ -20,6 +20,9 @@ class DownloadTaskParams {
|
|
|
20
20
|
String originHash;
|
|
21
21
|
// TASK_TYPE_CLEANUP only: entries younger than this survive; 0 = delete all
|
|
22
22
|
int maxAgeDays = 3;
|
|
23
|
+
// Absolute System.nanoTime deadline for orchestrated cold-start downloads;
|
|
24
|
+
// 0 keeps the normal public API's 10-minute per-call timeout.
|
|
25
|
+
long deadlineNanos;
|
|
23
26
|
File targetFile;
|
|
24
27
|
File unzipDirectory;
|
|
25
28
|
File originDirectory;
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
package cn.reactnative.modules.update;
|
|
2
|
+
|
|
3
|
+
import android.os.Build;
|
|
4
|
+
import android.util.Log;
|
|
5
|
+
import java.util.HashSet;
|
|
6
|
+
import java.util.concurrent.CountDownLatch;
|
|
7
|
+
import java.util.concurrent.TimeUnit;
|
|
8
|
+
import java.util.concurrent.atomic.AtomicBoolean;
|
|
9
|
+
import okhttp3.MediaType;
|
|
10
|
+
import okhttp3.OkHttpClient;
|
|
11
|
+
import okhttp3.Request;
|
|
12
|
+
import okhttp3.RequestBody;
|
|
13
|
+
import okhttp3.Response;
|
|
14
|
+
import org.json.JSONArray;
|
|
15
|
+
import org.json.JSONException;
|
|
16
|
+
import org.json.JSONObject;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Native cold-start update check (NATIVE_CHECKUPDATE_DESIGN §10): once per
|
|
20
|
+
* process, a few seconds after getBundleUrl, entirely independent of the app
|
|
21
|
+
* bundle — this is what lets a device bricked by a bad hot update pull the
|
|
22
|
+
* fixed version on the next launch. All decisions come from
|
|
23
|
+
* cpp/update_flow_core via NativeUpdateFlow; this class is IO glue only.
|
|
24
|
+
* Failures are silent and bounded: one round per launch, no retry storms, no
|
|
25
|
+
* version blacklisting.
|
|
26
|
+
*/
|
|
27
|
+
final class NativeCheckOrchestrator {
|
|
28
|
+
static final String KEY_CONFIG = "nativeConfig";
|
|
29
|
+
// Raw response cache for the JS side to reuse (§10.3), scoped to the
|
|
30
|
+
// exact logical request and native config that produced it.
|
|
31
|
+
static final String KEY_RESP_CACHE = "nativeCheckResp";
|
|
32
|
+
private static final int MAX_CHECK_HTTP_ATTEMPTS = 8;
|
|
33
|
+
private static final long DOWNLOAD_PHASE_TIMEOUT_SECONDS = 600;
|
|
34
|
+
|
|
35
|
+
private static final AtomicBoolean scheduled = new AtomicBoolean(false);
|
|
36
|
+
|
|
37
|
+
private NativeCheckOrchestrator() {
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static void schedule(final UpdateContext context, final String launchRolledBackVersion) {
|
|
41
|
+
if (UpdateContext.DEBUG) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!scheduled.compareAndSet(false, true)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
Thread thread = new Thread(new Runnable() {
|
|
48
|
+
@Override
|
|
49
|
+
public void run() {
|
|
50
|
+
try {
|
|
51
|
+
// Keep the check away from the cold-start critical path
|
|
52
|
+
// (§7 R5) — its result targets the NEXT launch anyway.
|
|
53
|
+
Thread.sleep(5000);
|
|
54
|
+
runOnce(context, launchRolledBackVersion);
|
|
55
|
+
} catch (Throwable e) {
|
|
56
|
+
// The rescue path must never take the app down with it.
|
|
57
|
+
Log.w(UpdateContext.TAG, "native check failed: " + e);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}, "pushy-native-check");
|
|
61
|
+
thread.setPriority(Thread.MIN_PRIORITY + 1);
|
|
62
|
+
thread.setDaemon(true);
|
|
63
|
+
thread.start();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private static void runOnce(
|
|
67
|
+
UpdateContext context,
|
|
68
|
+
String launchRolledBackVersion
|
|
69
|
+
) throws JSONException {
|
|
70
|
+
// Sampled before any IO: a reset landing while this round runs must
|
|
71
|
+
// win over the round's decision.
|
|
72
|
+
final long resetGeneration = UpdateContext.getResetGeneration();
|
|
73
|
+
String configJson = context.getKv(KEY_CONFIG);
|
|
74
|
+
if (configJson == null || configJson.isEmpty()) {
|
|
75
|
+
// No persisted config (old integration / first ever launch): the
|
|
76
|
+
// native check silently does not run — this is the rollout gate.
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
JSONObject config;
|
|
80
|
+
try {
|
|
81
|
+
config = new JSONObject(configJson);
|
|
82
|
+
} catch (JSONException e) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (config.optBoolean("disabled", false)) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
String appKey = config.optString("appKey", "");
|
|
89
|
+
if (appKey.isEmpty()) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
String packageVersion = config.optString(
|
|
93
|
+
"packageVersion", context.getPackageVersion());
|
|
94
|
+
if (packageVersion.isEmpty()) {
|
|
95
|
+
packageVersion = context.getPackageVersion();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
String currentVersion = context.getCurrentVersion();
|
|
99
|
+
// Snapshot captured on the launch path before getConstants consumes
|
|
100
|
+
// the one-shot rollback marker. Reading SharedPreferences here, five
|
|
101
|
+
// seconds later, would lose the guard and could forceBoot the version
|
|
102
|
+
// that this very launch just rolled back.
|
|
103
|
+
String rolledBackVersion = launchRolledBackVersion;
|
|
104
|
+
String uuid = context.getKv("uuid");
|
|
105
|
+
if (uuid == null) {
|
|
106
|
+
uuid = "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
JSONObject identity = new JSONObject();
|
|
110
|
+
identity.put("packageVersion", packageVersion);
|
|
111
|
+
identity.put(
|
|
112
|
+
"currentVersion",
|
|
113
|
+
currentVersion == null ? JSONObject.NULL : currentVersion
|
|
114
|
+
);
|
|
115
|
+
identity.put("uuid", uuid);
|
|
116
|
+
if (rolledBackVersion != null) {
|
|
117
|
+
identity.put("rolledBackVersion", rolledBackVersion);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
JSONObject cInfo = new JSONObject();
|
|
121
|
+
cInfo.put("rnu", config.optString("rnu", ""));
|
|
122
|
+
cInfo.put("rn", config.optString("rn", ""));
|
|
123
|
+
// React Native's Platform.Version is the Android SDK integer; use the
|
|
124
|
+
// same value so this request can be fingerprinted against the JS one.
|
|
125
|
+
cInfo.put("os", "android " + Build.VERSION.SDK_INT);
|
|
126
|
+
cInfo.put("uuid", uuid);
|
|
127
|
+
|
|
128
|
+
JSONObject input = new JSONObject();
|
|
129
|
+
input.put("packageVersion", packageVersion);
|
|
130
|
+
input.put(
|
|
131
|
+
"currentVersion",
|
|
132
|
+
currentVersion == null ? JSONObject.NULL : currentVersion
|
|
133
|
+
);
|
|
134
|
+
input.put("buildTime", context.getBuildTime());
|
|
135
|
+
input.put("cInfo", cInfo);
|
|
136
|
+
input.put("supportedDiffVersion", NativeUpdateCore.supportedDiffVersion());
|
|
137
|
+
input.put("bundleHash", context.computeBundleHash());
|
|
138
|
+
|
|
139
|
+
String body = NativeUpdateFlow.buildCheckRequestBody(input.toString());
|
|
140
|
+
if (body == null) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
String responseText = runCheckRequest(config, appKey, body);
|
|
145
|
+
if (responseText == null) {
|
|
146
|
+
Log.i(UpdateContext.TAG,
|
|
147
|
+
"native check: no endpoint reachable, giving up until next launch");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// Cache freshness is anchored to when the server response arrived,
|
|
151
|
+
// not to when a potentially long download/patch/activation finished.
|
|
152
|
+
final long responseAtSeconds = System.currentTimeMillis() / 1000;
|
|
153
|
+
|
|
154
|
+
String decisionJson = NativeUpdateFlow.handleCheckResponse(
|
|
155
|
+
responseText, identity.toString(), config.optString("afterDownload", ""));
|
|
156
|
+
if (decisionJson == null) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
JSONObject decision = new JSONObject(decisionJson);
|
|
160
|
+
if (!"download".equals(decision.optString("action"))) {
|
|
161
|
+
context.commitNativeCheckResult(
|
|
162
|
+
resetGeneration, null, null, false,
|
|
163
|
+
buildResponseCacheJson(configJson, body, responseText, responseAtSeconds));
|
|
164
|
+
Log.i(UpdateContext.TAG,
|
|
165
|
+
"native check: nothing to do (" + decision.optString("reason") + ")");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
String hash = decision.optString("hash", "");
|
|
169
|
+
if (!UpdateContext.isSafePathComponent(hash)) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
boolean downloaded = context.hasCompletedVersion(hash);
|
|
174
|
+
if (!downloaded) {
|
|
175
|
+
downloaded = performAttempts(
|
|
176
|
+
context, decision.optJSONArray("attempts"), hash, currentVersion);
|
|
177
|
+
}
|
|
178
|
+
if (!downloaded) {
|
|
179
|
+
// The native attempt has finished, so JS may safely reuse the
|
|
180
|
+
// response and retry through its own strategy chain.
|
|
181
|
+
context.commitNativeCheckResult(
|
|
182
|
+
resetGeneration, null, null, false,
|
|
183
|
+
buildResponseCacheJson(configJson, body, responseText, responseAtSeconds));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Version info (mirroring the JS side's setLocalHashInfo), the
|
|
188
|
+
// activation and the response cache all land in one atomic commit —
|
|
189
|
+
// see UpdateContext.commitNativeCheckResult.
|
|
190
|
+
String hashInfoJson = null;
|
|
191
|
+
JSONObject info = decision.optJSONObject("info");
|
|
192
|
+
if (info != null) {
|
|
193
|
+
JSONObject hashInfo = new JSONObject();
|
|
194
|
+
for (String key : new String[] {"name", "description", "metaInfo"}) {
|
|
195
|
+
Object value = info.opt(key);
|
|
196
|
+
if (value instanceof String) {
|
|
197
|
+
hashInfo.put(key, value);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
hashInfoJson = hashInfo.toString();
|
|
201
|
+
}
|
|
202
|
+
// Silent strategies or a server-marked forceBoot version (per-version
|
|
203
|
+
// remote override — the brick rescue) activate for the next launch;
|
|
204
|
+
// otherwise activation stays with the JS side.
|
|
205
|
+
boolean activate = decision.optBoolean("activate", false);
|
|
206
|
+
boolean committed;
|
|
207
|
+
try {
|
|
208
|
+
committed = context.commitNativeCheckResult(
|
|
209
|
+
resetGeneration,
|
|
210
|
+
hash,
|
|
211
|
+
hashInfoJson,
|
|
212
|
+
activate,
|
|
213
|
+
buildResponseCacheJson(configJson, body, responseText, responseAtSeconds));
|
|
214
|
+
} catch (Exception e) {
|
|
215
|
+
Log.w(UpdateContext.TAG, "native check: commit failed: " + e);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!committed) {
|
|
219
|
+
Log.i(UpdateContext.TAG, "native check: reset during round, dropping result");
|
|
220
|
+
} else if (activate) {
|
|
221
|
+
Log.i(UpdateContext.TAG,
|
|
222
|
+
"native check: downloaded " + hash + " and set for next launch");
|
|
223
|
+
} else {
|
|
224
|
+
Log.i(UpdateContext.TAG,
|
|
225
|
+
"native check: downloaded " + hash + ", activation left to JS");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private static String buildResponseCacheJson(
|
|
230
|
+
String configJson,
|
|
231
|
+
String requestBody,
|
|
232
|
+
String responseText,
|
|
233
|
+
long responseAtSeconds
|
|
234
|
+
) throws JSONException {
|
|
235
|
+
JSONObject cacheEntry = new JSONObject();
|
|
236
|
+
cacheEntry.put("ts", responseAtSeconds);
|
|
237
|
+
cacheEntry.put("body", responseText);
|
|
238
|
+
cacheEntry.put("request", requestBody);
|
|
239
|
+
cacheEntry.put("config", configJson);
|
|
240
|
+
return cacheEntry.toString();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private static final OkHttpClient httpClient = new OkHttpClient.Builder()
|
|
244
|
+
.connectTimeout(10, TimeUnit.SECONDS)
|
|
245
|
+
.readTimeout(10, TimeUnit.SECONDS)
|
|
246
|
+
.callTimeout(15, TimeUnit.SECONDS)
|
|
247
|
+
.build();
|
|
248
|
+
|
|
249
|
+
private static String httpRequest(String url, String postBody) {
|
|
250
|
+
try {
|
|
251
|
+
Request.Builder builder =
|
|
252
|
+
new Request.Builder().url(url).header("Accept", "application/json");
|
|
253
|
+
if (postBody != null) {
|
|
254
|
+
builder.post(RequestBody.create(
|
|
255
|
+
postBody, MediaType.parse("application/json; charset=utf-8")));
|
|
256
|
+
}
|
|
257
|
+
try (Response response = httpClient.newCall(builder.build()).execute()) {
|
|
258
|
+
if (!response.isSuccessful() || response.body() == null) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
return response.body().string();
|
|
262
|
+
}
|
|
263
|
+
} catch (Exception e) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private static boolean isValidCheckResponse(String responseText) {
|
|
269
|
+
if (responseText == null) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
new JSONObject(responseText);
|
|
274
|
+
return true;
|
|
275
|
+
} catch (JSONException e) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private static String normalizeEndpointBase(String base) {
|
|
281
|
+
while (base.endsWith("/")) {
|
|
282
|
+
base = base.substring(0, base.length() - 1);
|
|
283
|
+
}
|
|
284
|
+
return base;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Sequential fallback over the ordered candidates (§5.1): one request at
|
|
289
|
+
* a time with its own timeout; after the configured round fails,
|
|
290
|
+
* queryUrls discovery merges remote candidates (excluding the
|
|
291
|
+
* already-tried) for one more round. No hedged race on purpose — this
|
|
292
|
+
* path is latency-insensitive.
|
|
293
|
+
*/
|
|
294
|
+
private static String runCheckRequest(JSONObject config, String appKey, String body) {
|
|
295
|
+
JSONArray endpoints = config.optJSONArray("endpoints");
|
|
296
|
+
String orderedJson = NativeUpdateFlow.orderEndpointCandidates(
|
|
297
|
+
endpoints == null ? "[]" : endpoints.toString(), Math.random());
|
|
298
|
+
JSONArray ordered;
|
|
299
|
+
try {
|
|
300
|
+
ordered = orderedJson == null ? new JSONArray() : new JSONArray(orderedJson);
|
|
301
|
+
} catch (JSONException e) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
HashSet<String> tried = new HashSet<>();
|
|
305
|
+
int httpAttempts = 0;
|
|
306
|
+
for (int i = 0; i < ordered.length(); i++) {
|
|
307
|
+
String base = normalizeEndpointBase(ordered.optString(i, ""));
|
|
308
|
+
if (base.isEmpty() || !tried.add(base)) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
String response = httpRequest(base + "/checkUpdate/" + appKey, body);
|
|
315
|
+
if (isValidCheckResponse(response)) {
|
|
316
|
+
return response;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
JSONArray queryUrls = config.optJSONArray("queryUrls");
|
|
320
|
+
if (queryUrls == null) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
for (int i = 0; i < queryUrls.length(); i++) {
|
|
324
|
+
String listUrl = queryUrls.optString(i, "");
|
|
325
|
+
if (listUrl.isEmpty()) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
String listText = httpRequest(listUrl, null);
|
|
332
|
+
if (listText == null) {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
JSONArray remote;
|
|
336
|
+
try {
|
|
337
|
+
remote = new JSONArray(listText);
|
|
338
|
+
} catch (JSONException e) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
for (int j = 0; j < remote.length(); j++) {
|
|
342
|
+
String base = normalizeEndpointBase(remote.optString(j, ""));
|
|
343
|
+
if (base.isEmpty() || tried.contains(base)) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
tried.add(base);
|
|
350
|
+
String response = httpRequest(base + "/checkUpdate/" + appKey, body);
|
|
351
|
+
if (isValidCheckResponse(response)) {
|
|
352
|
+
return response;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// One successfully fetched remote list is enough.
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private static boolean performAttempts(
|
|
362
|
+
UpdateContext context, JSONArray attempts, String hash, String originHash
|
|
363
|
+
) {
|
|
364
|
+
if (attempts == null) {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
final long incrementalDeadlineNanos = System.nanoTime()
|
|
368
|
+
+ TimeUnit.SECONDS.toNanos(DOWNLOAD_PHASE_TIMEOUT_SECONDS);
|
|
369
|
+
long fullDeadlineNanos = 0;
|
|
370
|
+
for (int i = 0; i < attempts.length(); i++) {
|
|
371
|
+
JSONObject attempt = attempts.optJSONObject(i);
|
|
372
|
+
if (attempt == null) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
String type = attempt.optString("type");
|
|
376
|
+
if ("diff".equals(type) && (originHash == null || originHash.isEmpty())) {
|
|
377
|
+
// diff patches from the running version; none is running.
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
final boolean isFullAttempt = !"diff".equals(type) && !"pdiff".equals(type);
|
|
381
|
+
if (isFullAttempt && fullDeadlineNanos == 0) {
|
|
382
|
+
// Incremental failures must not consume the last-resort full
|
|
383
|
+
// download's budget. Each phase gets one bounded 10min window.
|
|
384
|
+
fullDeadlineNanos = System.nanoTime()
|
|
385
|
+
+ TimeUnit.SECONDS.toNanos(DOWNLOAD_PHASE_TIMEOUT_SECONDS);
|
|
386
|
+
}
|
|
387
|
+
final long deadlineNanos = isFullAttempt
|
|
388
|
+
? fullDeadlineNanos : incrementalDeadlineNanos;
|
|
389
|
+
JSONArray urls = attempt.optJSONArray("urls");
|
|
390
|
+
if (urls == null) {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
for (int j = 0; j < urls.length(); j++) {
|
|
394
|
+
String url = urls.optString(j, "");
|
|
395
|
+
if (url.isEmpty()) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
// Check before enqueueing: once the phase budget is gone we
|
|
399
|
+
// must not launch an orphan download that outlives the round.
|
|
400
|
+
long remainingNanos = deadlineNanos - System.nanoTime();
|
|
401
|
+
if (remainingNanos <= 0) {
|
|
402
|
+
if (isFullAttempt) {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
final CountDownLatch latch = new CountDownLatch(1);
|
|
408
|
+
final AtomicBoolean succeeded = new AtomicBoolean(false);
|
|
409
|
+
final String attemptType = type;
|
|
410
|
+
UpdateContext.DownloadFileListener listener =
|
|
411
|
+
new UpdateContext.DownloadFileListener() {
|
|
412
|
+
@Override
|
|
413
|
+
public void onDownloadCompleted(DownloadTaskParams params) {
|
|
414
|
+
succeeded.set(true);
|
|
415
|
+
latch.countDown();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
@Override
|
|
419
|
+
public void onDownloadFailed(Throwable error) {
|
|
420
|
+
Log.i(UpdateContext.TAG, "native check: " + attemptType
|
|
421
|
+
+ " attempt failed: " + error);
|
|
422
|
+
latch.countDown();
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
if ("diff".equals(type)) {
|
|
426
|
+
context.downloadPatchFromPpk(
|
|
427
|
+
url, hash, originHash, listener, deadlineNanos);
|
|
428
|
+
} else if ("pdiff".equals(type)) {
|
|
429
|
+
context.downloadPatchFromApk(
|
|
430
|
+
url, hash, listener, deadlineNanos);
|
|
431
|
+
} else {
|
|
432
|
+
context.downloadFullUpdate(
|
|
433
|
+
url, hash, listener, deadlineNanos);
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
if (!latch.await(remainingNanos, TimeUnit.NANOSECONDS)) {
|
|
437
|
+
Log.w(UpdateContext.TAG,
|
|
438
|
+
"native check: download phase timed out during " + type);
|
|
439
|
+
if (isFullAttempt) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
} catch (InterruptedException e) {
|
|
445
|
+
Thread.currentThread().interrupt();
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
if (succeeded.get()) {
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package cn.reactnative.modules.update;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JNI bindings to the shared update-flow decision layer
|
|
5
|
+
* (cpp/update_flow_core). String-in/string-out JSON on purpose — it matches
|
|
6
|
+
* the decision layer's own boundary and keeps this surface trivially stable.
|
|
7
|
+
* A null return means the input did not parse; callers skip the check round.
|
|
8
|
+
*/
|
|
9
|
+
final class NativeUpdateFlow {
|
|
10
|
+
static {
|
|
11
|
+
NativeUpdateCore.ensureLoaded();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
private NativeUpdateFlow() {
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static native String buildCheckRequestBody(String inputJson);
|
|
18
|
+
|
|
19
|
+
static native String orderEndpointCandidates(String endpointsJson, double randomSample);
|
|
20
|
+
|
|
21
|
+
static native String handleCheckResponse(
|
|
22
|
+
String responseText, String identityJson, String afterDownload);
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
package cn.reactnative.modules.update;
|
|
2
|
+
|
|
3
|
+
import java.io.IOException;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A download task failure that happened after the artifact was fully
|
|
7
|
+
* downloaded — unzip, hdiff apply, or bundled-resource copy (including the
|
|
8
|
+
* copiesCrc content verification). UpdateModuleImpl rejects these with
|
|
9
|
+
* PATCH_FAILED instead of DOWNLOAD_FAILED so the JS layer and server-side
|
|
10
|
+
* telemetry can separate patch health from network health.
|
|
11
|
+
*/
|
|
12
|
+
class PatchFailedException extends IOException {
|
|
13
|
+
PatchFailedException(String message, Throwable cause) {
|
|
14
|
+
super(message, cause);
|
|
15
|
+
}
|
|
16
|
+
}
|