Repository navigation
Expand file tree
/
Copy pathUpdateModule.java
More file actions
419 lines (383 loc) Β· 15.6 KB
/
Copy pathUpdateModule.java
File metadata and controls
419 lines (383 loc) Β· 15.6 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
package cn.reactnative.modules.update;
import static cn.reactnative.modules.update.StateSerialRunner.STATE_SERIAL;
import static cn.reactnative.modules.update.StateSerialRunner.UI_THREAD;
import android.util.Log;
import androidx.annotation.Nullable;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import java.util.HashMap;
import java.util.Map;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;
/**
* The "Pushy" native module for both architectures. NativePushySpec is the
* codegen spec under the new architecture and the hand-written stand-in in
* src/oldarch otherwise; the old bridge discovers methods through the
* @ReactMethod annotations here, so every spec method carries one. Each
* state-changing method takes a trailing Promise, so a failed persistence
* rejects instead of resolving early (CODE_AUDIT 2.3).
*/
public class UpdateModule extends NativePushySpec {
// Download URL option key, stored encoded.
private static final String OPTION_URL = Texts.OPTION_URL_KEY;
private final ReactApplicationContext reactContext;
private final UpdateContext updateContext;
public UpdateModule(ReactApplicationContext reactContext) {
this(reactContext, UpdateContext.getInstance(reactContext));
}
public UpdateModule(ReactApplicationContext reactContext, UpdateContext updateContext) {
super(reactContext);
this.reactContext = reactContext;
this.updateContext = updateContext;
UpdateEventEmitter.register(reactContext);
}
@Override
protected Map<String, Object> getTypedExportedConstants() {
final Map<String, Object> constants = new HashMap<String, Object>();
constants.put("downloadRootDir", updateContext.getRootDir());
constants.put("packageVersion", updateContext.getPackageVersion());
String currentVersion = updateContext.getCurrentVersion();
constants.put("currentVersion", currentVersion);
constants.put("currentVersionInfo", updateContext.getKv("hash_" + currentVersion));
// Served from the value cached at launch resolution (no file read on
// the JS thread) when currentVersion is the launched version.
constants.put("currentBundleSha256", updateContext.currentBundleSha256(currentVersion));
constants.put("buildTime", updateContext.getBuildTime());
constants.put("isUsingBundleUrl", updateContext.getIsUsingBundleUrl());
// Both one-shot markers are consumed in a single commit: under
// TurboModules this runs synchronously on the JS thread.
UpdateContext.LaunchMarkers markers = updateContext.consumeLaunchMarkers();
constants.put("isFirstTime", markers.isFirstTime);
constants.put("rolledBackVersion", markers.rolledBackVersion);
constants.put("uuid", updateContext.getKv("uuid"));
int supportedDiffVersion = 0;
try {
supportedDiffVersion = NativeCore.supportedDiffVersion();
} catch (UnsatisfiedLinkError e) {
// A mismatched librnpushy.so (stale manual copy / build cache)
// must not crash startup via getConstants; 0 simply means "no v2
// diff track" and the server degrades gracefully.
Log.e(Texts.LOG_TAG, "supportedDiffVersion missing from the native library", e);
}
constants.put("supportedDiffVersion", supportedDiffVersion);
return constants;
}
/**
* Reads a required string option. A module call runs on the native
* modules thread, where anything thrown (a missing key, a wrong type)
* reaches the bridge's exception handler and kills the app, so a bad
* argument has to come back as an INVALID_OPTIONS rejection instead
* (CODE_AUDIT 2.11). Returns null after rejecting.
*/
@Nullable
private static String readRequiredString(
@Nullable ReadableMap options,
String key,
Promise promise
) {
String value = null;
try {
if (options != null && options.hasKey(key) && !options.isNull(key)) {
value = options.getString(key);
}
} catch (RuntimeException e) {
// Wrong type: treated like a missing key below.
}
return requireNonEmpty(value, "options." + key, promise);
}
/** Same contract for a plain string argument. */
@Nullable
private static String requireNonEmpty(
@Nullable String value,
String name,
Promise promise
) {
if (value == null || value.isEmpty()) {
promise.reject(ErrorCodes.INVALID_OPTIONS, name + " must be a non-empty string");
return null;
}
return value;
}
private static boolean isValidHashInfo(@Nullable String json) {
if (json == null) {
return false;
}
try {
return new JSONTokener(json).nextValue() instanceof JSONObject;
} catch (JSONException e) {
return false;
}
}
private static UpdateContext.DownloadFileListener downloadListener(final Promise promise) {
return new UpdateContext.DownloadFileListener() {
@Override
public void onDownloadCompleted(DownloadTaskParams params) {
promise.resolve(null);
}
@Override
public void onDownloadFailed(Throwable error) {
promise.reject(ErrorCodes.forDownloadFailure(error), error);
}
};
}
@ReactMethod
@Override
public void downloadFullUpdate(final ReadableMap options, final Promise promise) {
String url = readRequiredString(options, OPTION_URL, promise);
if (url == null) {
return;
}
String hash = readRequiredString(options, "hash", promise);
if (hash == null) {
return;
}
updateContext.downloadFullUpdate(url, hash, downloadListener(promise), 0);
}
@ReactMethod
@Override
public void downloadAndInstallApk(final ReadableMap options, final Promise promise) {
// Every failure of this path β a missing REQUEST_INSTALL_PACKAGES
// declaration above all β has to come back as a promise rejection,
// never as an exception on the native modules thread.
try {
final String url = readRequiredString(options, "url", promise);
if (url == null) {
return;
}
String hash = readRequiredString(options, "hash", promise);
if (hash == null) {
return;
}
String target = readRequiredString(options, "target", promise);
if (target == null) {
return;
}
if (!HttpUtils.isHttpsUrl(url)) {
// Installing an APK is code execution and nothing verifies
// its bytes yet (CODE_AUDIT 2.6): TLS is the only thing
// standing between the server and the device, so a plaintext
// URL is refused outright.
promise.reject(ErrorCodes.INVALID_OPTIONS, "APK url must use https: " + url);
return;
}
if (!ApkInstaller.ensureInstallPermission(reactContext, promise)) {
return;
}
updateContext.downloadFile(url, hash, target, new UpdateContext.DownloadFileListener() {
@Override
public void onDownloadCompleted(DownloadTaskParams params) {
ApkInstaller.install(reactContext, params.targetFile, url, promise);
}
@Override
public void onDownloadFailed(Throwable error) {
promise.reject(ErrorCodes.DOWNLOAD_FAILED, error);
}
});
} catch (Throwable error) {
promise.reject(ErrorCodes.APK_INSTALL_FAILED, error);
}
}
@ReactMethod
@Override
public void downloadPatchFromPackage(final ReadableMap options, final Promise promise) {
String url = readRequiredString(options, OPTION_URL, promise);
if (url == null) {
return;
}
String hash = readRequiredString(options, "hash", promise);
if (hash == null) {
return;
}
updateContext.downloadPatchFromApk(url, hash, downloadListener(promise), 0);
}
@ReactMethod
@Override
public void downloadPatchFromPpk(final ReadableMap options, final Promise promise) {
String url = readRequiredString(options, OPTION_URL, promise);
if (url == null) {
return;
}
String hash = readRequiredString(options, "hash", promise);
if (hash == null) {
return;
}
String originHash = readRequiredString(options, "originHash", promise);
if (originHash == null) {
return;
}
updateContext.downloadPatchFromPpk(url, hash, originHash, downloadListener(promise), 0);
}
@ReactMethod
@Override
public void reloadUpdate(final ReadableMap options, final Promise promise) {
String hash = readRequiredString(options, "hash", promise);
if (hash == null) {
return;
}
restart(hash, promise);
}
@ReactMethod
@Override
public void restartApp(Promise promise) {
restart(null, promise);
}
private void restart(@Nullable final String hash, final Promise promise) {
// Two hops (CODE_AUDIT 2.2): the switch (bundle digest + commit) and
// the launch resolution run on the state serial thread like every
// other state write β nothing there touches React; only the React
// reload itself is posted to the UI thread. A failure at either hop
// rejects with RESTART_FAILED.
StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.RESTART_FAILED, "restartApp", () -> {
if (hash != null) {
updateContext.switchVersion(hash);
}
// The bundle to load; null for the packaged one.
final String bundlePath = updateContext.getBundleUrl();
StateSerialRunner.run(UI_THREAD, promise, ErrorCodes.RESTART_FAILED, "restartApp", () -> {
ReactReloadManager.reload(updateContext, reactContext, bundlePath);
promise.resolve(true);
});
});
}
@ReactMethod
@Override
public void setNeedUpdate(final ReadableMap options, final Promise promise) {
final String hash = readRequiredString(options, "hash", promise);
if (hash == null) {
return;
}
StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.SWITCH_VERSION_FAILED, "switchVersionLater", () -> {
updateContext.switchVersion(hash);
promise.resolve(true);
});
}
@ReactMethod
@Override
public void markSuccess(final Promise promise) {
StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.MARK_SUCCESS_FAILED, "markSuccess", () -> {
updateContext.markSuccess();
promise.resolve(true);
});
}
@ReactMethod
@Override
public void getBundleHash(final Promise promise) {
// Threading lives in UpdateContext (its bundle-hash thread); resolve is
// thread-safe. Never rejects β empty string means "unknown".
updateContext.getBundleHash(promise::resolve);
}
@ReactMethod
@Override
public void resetToPackagedBundle(final Promise promise) {
StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.RESET_FAILED, "resetToPackagedBundle", () -> {
updateContext.resetToPackagedBundle();
promise.resolve(true);
});
}
/**
* Raw response cached by the native cold-start check, for the JS side to
* reuse instead of re-checking (Β§10.3). Empty string when absent; never
* rejects.
*/
@ReactMethod
@Override
public void getNativeCheckCache(final Promise promise) {
String cached = updateContext.getNativeCheckCache();
promise.resolve(cached == null ? "" : cached);
}
/**
* JS obtained a valid check response for this config in this process; the
* delayed native round then skips its duplicate request (Β§10.3).
*/
@ReactMethod
@Override
public void markJsCheckCompleted(final String config, final Promise promise) {
if (requireNonEmpty(config, "config", promise) == null) {
return;
}
SyncCoordinator.recordJsRound(config);
promise.resolve(true);
}
/**
* Provisioning for the native cold-start update check
* (NATIVE_CHECKUPDATE_DESIGN Β§10.1): the raw JSON persists as-is and is
* parsed on read by the orchestrator; absent config = check disabled.
* Validated at write time β a corrupt config would otherwise silently
* disable the native check forever with no signal.
*/
@ReactMethod
@Override
public void syncNativeConfig(final String config, final Promise promise) {
if (requireNonEmpty(config, "config", promise) == null) {
return;
}
try {
new JSONObject(config);
} catch (JSONException e) {
promise.reject(ErrorCodes.INVALID_OPTIONS, "config must be a JSON object string", e);
return;
}
StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.FILE_OPERATION_FAILED, "syncNativeConfig", () -> {
updateContext.setNativeConfig(config);
promise.resolve(true);
});
}
@ReactMethod
@Override
public void setUuid(final String uuid, final Promise promise) {
if (requireNonEmpty(uuid, "uuid", promise) == null) {
return;
}
StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.FILE_OPERATION_FAILED, "setUuid", () -> {
updateContext.setKv("uuid", uuid);
promise.resolve(true);
});
}
@ReactMethod
@Override
public void setLocalHashInfo(final String hash, final String info, final Promise promise) {
if (requireNonEmpty(hash, "hash", promise) == null) {
return;
}
if (!isValidHashInfo(info)) {
promise.reject(ErrorCodes.INVALID_HASH_INFO, "setLocalHashInfo failed: invalid json string");
return;
}
// A state write that did not reach disk (SharedPreferences commit
// false) is FILE_OPERATION_FAILED, distinct from malformed input.
StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.FILE_OPERATION_FAILED, "setLocalHashInfo", () -> {
updateContext.setKv("hash_" + hash, info);
promise.resolve(true);
});
}
@ReactMethod
@Override
public void getLocalHashInfo(final String hash, final Promise promise) {
if (requireNonEmpty(hash, "hash", promise) == null) {
return;
}
String value = updateContext.getKv("hash_" + hash);
if (value == null || value.isEmpty()) {
// No record (iOS resolves nil the same way): JS treats it as {}.
// Only a record that exists and is not a JSON object is invalid.
promise.resolve(null);
return;
}
if (!isValidHashInfo(value)) {
promise.reject(ErrorCodes.INVALID_HASH_INFO, "getLocalHashInfo failed: invalid json string");
return;
}
promise.resolve(value);
}
@ReactMethod
@Override
public void addListener(String eventName) {
}
@ReactMethod
@Override
public void removeListeners(double count) {
}
}