Repository navigation
Expand file tree
/
Copy pathUpdateContext.java
More file actions
994 lines (908 loc) Β· 40.1 KB
/
Copy pathUpdateContext.java
File metadata and controls
994 lines (908 loc) Β· 40.1 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
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
package cn.reactnative.modules.update;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.util.Log;
import androidx.annotation.Nullable;
import com.facebook.react.ReactInstanceManager;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.Executor;
public class UpdateContext {
// The one place that loads librnpushy.so (see NativeCore).
static {
NativeCore.ensureLoaded();
}
static final String TAG = Texts.LOG_TAG;
private final Context context;
private final File rootDir;
private final Executor executor;
private final SharedPreferences sp;
// The native check's raw response cache (SyncCoordinator.KEY_RESP_CACHE)
// lives in its own file: it is a whole server response, and keeping it in
// sp made every small state commit rewrite it.
private final SharedPreferences checkCacheSp;
// Our own PackageInfo, looked up once: every getPackageInfo call is a
// Binder IPC, and the constructor, the bundle-hash cache and the native
// check all need the version name / install time.
@Nullable
private final PackageInfo packageInfo;
// Written under sLock (setCustomInstanceManager), read on the restart
// path without it.
private volatile ReactInstanceManager reactInstanceManager;
// Written on the launch path, read from executor/JS threads.
private volatile boolean isUsingBundleUrl;
private volatile boolean ignoreRollback;
// The version whose bundle this process actually loaded (resolved in
// getBundleUrl). resetToPackagedBundle must not delete its directory:
// update assets (images/fonts) are read from it on demand at runtime, so
// wiping it under a silent (no-restart) reset would break every image the
// running app has not loaded yet. Volatile: written during launch, read
// from the state serial and download executors.
private volatile String launchVersion;
// bundleSha256 of launchVersion's install record, read once at launch
// resolution so getConstants does not hit the file system on the JS
// thread. {hash, sha256}; null until a version was resolved.
private volatile String[] launchBundleSha256;
// bundleHash of the embedded bundle, computed once per process on its own
// thread (see startBundleHashComputation); null until known.
private final Object bundleHashLock = new Object();
private String bundleHash;
private java.util.ArrayList<BundleHashListener> pendingBundleHashListeners =
new java.util.ArrayList<>();
// <generated:state-operations> (scripts/contract.ts; bun scripts/generate-contract.ts)
private static final int STATE_OP_SWITCH_VERSION = 1;
private static final int STATE_OP_MARK_SUCCESS = 2;
private static final int STATE_OP_ROLLBACK = 3;
// 4 (ClearFirstTime) has no caller on this side.
private static final int STATE_OP_CLEAR_ROLLBACK_MARK = 5;
private static final int STATE_OP_RESOLVE_LAUNCH = 6;
// </generated:state-operations>
static final String KEY_FIRST_LOAD_MARKED = "firstLoadMarked";
// Bumped by resetToPackagedBundle. The cold-start check runs for minutes
// and may already hold a decision when the app resets to the packaged
// bundle; the orchestrator samples this counter and abandons activation
// (and its response cache) when the value moved, so an in-flight rescue
// can never resurrect the version the app just reset away from.
private static final java.util.concurrent.atomic.AtomicLong resetGeneration =
new java.util.concurrent.atomic.AtomicLong(0);
// The one mutex for every snapshot -> state core -> commit sequence
// (switchVersion, markSuccess, the launch resolution and its rollback,
// the launch markers, the cold-start round's commit,
// resetToPackagedBundle). They are read-modify-write cycles on the same
// SharedPreferences from different threads (state serial executor,
// native check thread, JS thread); without a common lock a commit built
// on an older snapshot silently undoes an earlier one β e.g. a JS
// markSuccess erasing the activation a crash-rescue round just committed
// (CODE_AUDIT 2.1). Intrinsic locks are reentrant, so nested takers such
// as getBundleUrl -> rollBack are fine. Nothing slow runs inside it: the
// bundle digest of a switch is verified before the lock is taken.
private static final Object commitLock = new Object();
// Cleanup and activation share this lock from directory verification through
// deletion/state commit. Slow filesystem work still stays outside commitLock.
private static final Object versionFilesLock = new Object();
// Singleton instance
private static volatile UpdateContext sInstance;
private static final Object sLock = new Object();
private static ReactInstanceManager pendingReactInstanceManager;
private static native StateCoreResult syncStateWithBinaryVersion(
String packageVersion,
String buildTime,
StateCoreResult state
);
private static native StateCoreResult runStateCore(
int operation,
StateCoreResult state,
String stringArg,
boolean flagA,
boolean flagB
);
private UpdateContext(Context context) {
this.context = context.getApplicationContext();
this.executor = Threads.single("pushy-download", false);
this.rootDir = new File(this.context.getFilesDir(), Texts.STORAGE_DIR_NAME);
if (!rootDir.exists() && !rootDir.mkdirs() && !rootDir.exists()) {
throw new IllegalStateException("Failed to create storage root dir: " + rootDir);
}
this.sp = this.context.getSharedPreferences(Texts.PREFERENCES_NAME, Context.MODE_PRIVATE);
this.checkCacheSp = this.context.getSharedPreferences(
Texts.PREFERENCES_NAME + "_check", Context.MODE_PRIVATE);
this.packageInfo = lookupPackageInfo(this.context);
this.reactInstanceManager = pendingReactInstanceManager;
String packageVersion = getPackageVersion();
String buildTime = getBuildTime();
StateCoreResult nextState = syncStateWithBinaryVersion(
packageVersion,
buildTime,
getStateSnapshot()
);
if (nextState.changed) {
// Execute cleanUp before clearing SharedPreferences to avoid race condition
cleanUp(DownloadTaskParams.DEFAULT_MAX_AGE_DAYS);
SharedPreferences.Editor editor = this.sp.edit();
removeVersionScopedKeys(editor);
applyState(editor, nextState);
persistEditor(editor, "sync state with binary version");
clearNativeCheckCache();
} else {
migrateNativeCheckCache();
}
startBundleHashComputation();
}
// One-time move of the response cache out of the main state file.
private void migrateNativeCheckCache() {
String legacy = sp.getString(SyncCoordinator.KEY_RESP_CACHE, null);
if (legacy == null) {
return;
}
if (checkCacheSp.edit().putString(SyncCoordinator.KEY_RESP_CACHE, legacy).commit()) {
SharedPreferences.Editor editor = sp.edit();
editor.remove(SyncCoordinator.KEY_RESP_CACHE);
persistEditor(editor, "migrate check cache");
}
}
public String getRootDir() {
return rootDir.toString();
}
@Nullable
private static PackageInfo lookupPackageInfo(Context context) {
try {
return context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
} catch (PackageManager.NameNotFoundException e) {
// Our own package is always installed; a failure here is a
// platform bug worth a log line, not a crash on the launch path.
Log.e(TAG, "Unable to read own package info", e);
return null;
}
}
public String getPackageVersion() {
return packageInfo == null ? null : packageInfo.versionName;
}
public String getBuildTime() {
return context.getString(R.string.pushy_build_time);
}
public boolean getIsUsingBundleUrl() {
return isUsingBundleUrl;
}
// bundleHash cache: "<packageVersion>|<lastUpdateTime>|<sha256hex>". The
// key identifies the installed binary; every (re)install changes
// lastUpdateTime, so the hash is recomputed once per install.
private static final String KEY_BUNDLE_HASH_CACHE = "bundleHashCache";
public interface BundleHashListener {
void onBundleHash(String hash);
}
/**
* bundleHash = sha256 of the JS bundle embedded in the binary β the
* identity of the binary itself, not of whatever hot update is currently
* running. Hashes exactly the bytes pdiff patches from: the hardcoded
* "index.android.bundle" asset read via AssetManager, same as
* DownloadTask.copyBundledAssetToFile. Never fails β an empty string
* means "unknown" and the server falls back to the buildTime heuristic.
*
* Computed once per process on a dedicated thread started by the
* constructor (not on the download executor, where it used to queue
* behind cleanup and downloads and make the first JS checkUpdate send an
* empty hash). The listener runs on that thread, or on the caller's when
* the value is already known.
*
* java.security.MessageDigest rather than the C++ pushy::digest: the
* bytes come from an AssetManager stream on the Java side, and the
* platform digest is native-backed already. The NIST vectors in the
* patch_core tests anchor both implementations to the same standard.
*/
public void getBundleHash(final BundleHashListener listener) {
String known;
synchronized (bundleHashLock) {
known = bundleHash;
if (known == null) {
pendingBundleHashListeners.add(listener);
return;
}
}
listener.onBundleHash(known);
}
// Package-private: also the native cold-start check's request input
// (SyncCoordinator). Blocks until the shared computation finished β call
// off the main thread.
String computeBundleHash() {
synchronized (bundleHashLock) {
while (bundleHash == null) {
try {
bundleHashLock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "";
}
}
return bundleHash;
}
}
private void startBundleHashComputation() {
if (BuildConfig.DEBUG) {
// Metro serves the bundle in debug; mirror the dev buildTime behaviour.
publishBundleHash("");
return;
}
Threads.startDaemon("pushy-bundle-hash", () -> {
String hash = "";
try {
hash = loadOrHashBundledAsset();
} catch (Throwable e) {
Log.w(TAG, "bundle hash failed: " + e);
} finally {
publishBundleHash(hash);
}
});
}
private void publishBundleHash(String hash) {
java.util.ArrayList<BundleHashListener> listeners;
synchronized (bundleHashLock) {
bundleHash = hash;
listeners = pendingBundleHashListeners;
pendingBundleHashListeners = null;
bundleHashLock.notifyAll();
}
if (listeners != null) {
for (BundleHashListener listener : listeners) {
try {
listener.onBundleHash(hash);
} catch (Throwable e) {
Log.w(TAG, "bundle hash listener failed: " + e);
}
}
}
}
private String loadOrHashBundledAsset() {
String cachePrefix = getPackageVersion() + "|" + getPackageLastUpdateTime() + "|";
String cached = sp.getString(KEY_BUNDLE_HASH_CACHE, null);
if (cached != null && cached.startsWith(cachePrefix)) {
return cached.substring(cachePrefix.length());
}
String hash = sha256OfBundledAsset("index.android.bundle");
if (!hash.isEmpty()) {
SharedPreferences.Editor editor = sp.edit();
editor.putString(KEY_BUNDLE_HASH_CACHE, cachePrefix + hash);
persistEditor(editor, "cache bundle hash");
}
return hash;
}
private long getPackageLastUpdateTime() {
return packageInfo == null ? 0 : packageInfo.lastUpdateTime;
}
private String sha256OfBundledAsset(String assetName) {
try (InputStream in = context.getAssets().open(assetName)) {
return UpdateFileUtils.sha256Hex(in);
} catch (IOException e) {
// Expected when there is no embedded bundle (custom name, no
// release bundle); "unknown" is a valid answer, not an error.
Log.i(TAG, "Cannot hash bundled asset " + assetName + ": " + e);
return "";
}
}
private void enqueue(DownloadTaskParams params) {
executor.execute(new DownloadTask(context, params));
}
private static boolean rejectUnsafeComponent(String name, DownloadFileListener listener) {
if (UpdateFileUtils.isSafePathComponent(name)) {
return false;
}
listener.onDownloadFailed(new IllegalArgumentException("Invalid path component: " + name));
return true;
}
public interface DownloadFileListener {
void onDownloadCompleted(DownloadTaskParams params);
void onDownloadFailed(Throwable error);
}
/**
* Params shared by every download task. deadlineNanos is an absolute
* System.nanoTime deadline for orchestrated (native cold-start) tasks; 0
* keeps the per-call timeout of a JS-initiated download.
*/
private static DownloadTaskParams downloadParams(
int type, String url, String hash, DownloadFileListener listener, long deadlineNanos
) {
DownloadTaskParams params = new DownloadTaskParams();
params.type = type;
params.url = url;
params.hash = hash;
params.listener = listener;
params.deadlineNanos = deadlineNanos;
return params;
}
/*
* The patch downloads return the queued task's params so the native
* orchestrator can cancel the task when its phase budget expires
* (CODE_AUDIT 2.12); null when the request was rejected up front (the
* listener has already been told).
*/
@Nullable
DownloadTaskParams downloadFullUpdate(
String url, String hash, DownloadFileListener listener, long deadlineNanos
) {
if (rejectUnsafeComponent(hash, listener)) {
return null;
}
DownloadTaskParams params = downloadParams(
DownloadTaskParams.TASK_TYPE_PATCH_FULL, url, hash, listener, deadlineNanos);
params.targetFile = new File(rootDir, hash + ".ppk");
params.unzipDirectory = new File(rootDir, hash);
enqueue(params);
return params;
}
/** A plain file download into the root dir; hash only keys progress events. */
public void downloadFile(String url, String hash, String fileName, DownloadFileListener listener) {
if (rejectUnsafeComponent(fileName, listener)) {
return;
}
DownloadTaskParams params = downloadParams(
DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD, url, hash, listener, 0);
params.targetFile = new File(rootDir, fileName);
enqueue(params);
}
@Nullable
DownloadTaskParams downloadPatchFromApk(
String url, String hash, DownloadFileListener listener, long deadlineNanos
) {
if (rejectUnsafeComponent(hash, listener)) {
return null;
}
DownloadTaskParams params = downloadParams(
DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK, url, hash, listener, deadlineNanos);
params.targetFile = new File(rootDir, hash + Texts.PACKAGE_DELTA_SUFFIX);
params.unzipDirectory = new File(rootDir, hash);
enqueue(params);
return params;
}
@Nullable
DownloadTaskParams downloadPatchFromPpk(
String url,
String hash,
String originHash,
DownloadFileListener listener,
long deadlineNanos
) {
if (rejectUnsafeComponent(hash, listener) || rejectUnsafeComponent(originHash, listener)) {
return null;
}
DownloadTaskParams params = downloadParams(
DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK, url, hash, listener, deadlineNanos);
params.originHash = originHash;
params.targetFile = new File(rootDir, originHash + "-" + hash + Texts.PPK_DELTA_SUFFIX);
params.unzipDirectory = new File(rootDir, hash);
params.originDirectory = new File(rootDir, originHash);
enqueue(params);
return params;
}
private StateCoreResult getStateSnapshot() {
StateCoreResult state = new StateCoreResult();
state.packageVersion = sp.getString("packageVersion", null);
state.buildTime = sp.getString("buildTime", null);
state.currentVersion = sp.getString("currentVersion", null);
state.lastVersion = sp.getString("lastVersion", null);
state.firstTime = sp.getBoolean("firstTime", false);
state.firstTimeOk = sp.getBoolean("firstTimeOk", true);
state.rolledBackVersion = sp.getString("rolledBackVersion", null);
return state;
}
private static void putNullableString(
SharedPreferences.Editor editor,
String key,
String value
) {
if (value == null) {
editor.remove(key);
} else {
editor.putString(key, value);
}
}
// Not editor.clear(): that also wiped the uuid (re-bucketing every gray
// release on each app upgrade) and the native config (no crash hold and
// no cold-start round until JS ran again). See VersionScopedKeys.
private void removeVersionScopedKeys(SharedPreferences.Editor editor) {
for (String key : VersionScopedKeys.select(sp.getAll().keySet())) {
editor.remove(key);
}
}
private void applyState(SharedPreferences.Editor editor, StateCoreResult state) {
putNullableString(editor, "packageVersion", state.packageVersion);
putNullableString(editor, "buildTime", state.buildTime);
putNullableString(editor, "currentVersion", state.currentVersion);
putNullableString(editor, "lastVersion", state.lastVersion);
editor.putBoolean("firstTime", state.firstTime);
editor.putBoolean("firstTimeOk", state.firstTimeOk);
putNullableString(editor, "rolledBackVersion", state.rolledBackVersion);
}
/**
* Best-effort persistence for launch-path bookkeeping (binary-version
* sync, first-load markers, rollback resolution): a failure is logged but
* must not take the app down while it is resolving which bundle to run.
*/
private boolean persistEditor(SharedPreferences.Editor editor, String reason) {
// A lost state write can mean a missed rollback or a version switch
// that silently never happens, so this must be visible in release too.
if (!editor.commit()) {
Log.e(TAG, "Failed to persist state for " + reason);
return false;
}
return true;
}
/**
* Persistence for operations whose result is promised to a caller
* (switchVersion / markSuccess / setLocalHashInfo / the cold-start round):
* a commit that did not reach disk is a failed operation, never a
* resolved promise whose effect silently evaporates on the next launch.
*/
private void persistEditorOrThrow(SharedPreferences.Editor editor, String reason) {
if (!persistEditor(editor, reason)) {
throw new IllegalStateException("Failed to persist state for " + reason);
}
}
/**
* Validates that {@code hash} is a switchable version: the slow half of
* a switch (file checks and the bundle digest of a multi-MB bundle),
* deliberately run without commitLock. The state snapshot read here only
* decides whether a markerless directory is grandfathered; a concurrent
* commit cannot make an unverified directory pass.
*/
private void verifySwitchTarget(String hash) {
if (!UpdateFileUtils.isSafePathComponent(hash)) {
throw new IllegalArgumentException("Invalid hash: " + hash);
}
StateCoreResult currentState = getStateSnapshot();
// Versions activated before completion records existed are
// grandfathered through current/last state, but only while their
// directory has no record; any other directory must carry a record
// whose bundle digest matches the bytes on disk before the next
// launch is pointed at them.
boolean grandfathered = hash.equals(currentState.currentVersion)
|| hash.equals(currentState.lastVersion);
try {
InstallRecord.verifyInstalled(new File(rootDir, hash), hash, grandfathered);
} catch (IOException e) {
throw new IllegalStateException(e.getMessage(), e);
}
}
/**
* The fast half of a switch: snapshot -> state core, without persisting,
* so callers can fold it into a larger single-commit transaction. Caller
* holds commitLock and has run verifySwitchTarget.
*/
private StateCoreResult computeSwitchState(String hash) {
return runStateOp(STATE_OP_SWITCH_VERSION, hash);
}
/** One state-core operation on the current snapshot; caller holds commitLock. */
private StateCoreResult runStateOp(int operation, @Nullable String hash) {
return runStateCore(operation, getStateSnapshot(), hash, false, false);
}
public void switchVersion(String hash) {
synchronized (versionFilesLock) {
verifySwitchTarget(hash);
synchronized (commitLock) {
StateCoreResult nextState = computeSwitchState(hash);
SharedPreferences.Editor editor = sp.edit();
applyState(editor, nextState);
persistEditorOrThrow(editor, "switch version");
ignoreRollback = false;
}
}
}
public void setKv(String key, String value) {
SharedPreferences.Editor editor = sp.edit();
editor.putString(key, value);
persistEditorOrThrow(editor, "set key " + key);
}
public String getKv(String key) {
return sp.getString(key, null);
}
void removeKv(String key) {
SharedPreferences.Editor editor = sp.edit();
editor.remove(key);
persistEditorOrThrow(editor, "remove key " + key);
}
public String getCurrentVersion() {
return sp.getString("currentVersion", null);
}
/** The one-shot launch markers getConstants reports, consumed together. */
static final class LaunchMarkers {
final boolean isFirstTime;
@Nullable
final String rolledBackVersion;
LaunchMarkers(boolean isFirstTime, @Nullable String rolledBackVersion) {
this.isFirstTime = isFirstTime;
this.rolledBackVersion = rolledBackVersion;
}
}
/**
* Reads and clears the first-load marker and the rollback mark in one
* editor. getConstants runs this synchronously on the JS thread (a
* TurboModule constant read), so the write is applied asynchronously;
* persistence is best-effort on this launch path.
*/
LaunchMarkers consumeLaunchMarkers() {
LaunchMarkers markers;
synchronized (commitLock) {
boolean isFirstLoadMarked = sp.getBoolean(KEY_FIRST_LOAD_MARKED, false);
String rolledBackVersion = rolledBackVersion();
markers = new LaunchMarkers(isFirstLoadMarked, rolledBackVersion);
if (isFirstLoadMarked || rolledBackVersion != null) {
SharedPreferences.Editor editor = sp.edit();
if (isFirstLoadMarked) {
editor.remove(KEY_FIRST_LOAD_MARKED);
}
if (rolledBackVersion != null) {
applyState(editor, runStateOp(STATE_OP_CLEAR_ROLLBACK_MARK, null));
}
// Asynchronous on purpose: the in-memory state changes
// immediately, the disk write happens on the
// SharedPreferences writer. Losing it to a crash only
// re-reports the one-shot markers on the next launch.
editor.apply();
}
}
if (markers.rolledBackVersion != null) {
// The rolled-back version's directory is now eligible for cleanup.
cleanUp(DownloadTaskParams.DEFAULT_MAX_AGE_DAYS);
}
return markers;
}
public String rolledBackVersion() {
return sp.getString("rolledBackVersion", null);
}
public void markSuccess() {
if (BuildConfig.DEBUG) {
return;
}
synchronized (commitLock) {
StateCoreResult nextState = runStateOp(STATE_OP_MARK_SUCCESS, null);
SharedPreferences.Editor editor = sp.edit();
applyState(editor, nextState);
if (nextState.staleVersionToDelete != null) {
editor.remove("hash_" + nextState.staleVersionToDelete);
}
persistEditorOrThrow(editor, "mark success");
}
cleanUp(DownloadTaskParams.DEFAULT_MAX_AGE_DAYS);
}
/**
* Reset to the bundle packaged in the binary: wipe the whole update state
* (so the next launch resolves to the built-in bundle) and delete the
* downloaded versions, keeping only the directory of the version this
* process is running from (a silent reset must not break its on-demand
* asset loads). The install's own keys survive (VersionScopedKeys): the
* client uuid identifies the install for gray release bucketing and must
* not change on reset, and the native config stays with the host, as on
* iOS and Harmony.
*/
public void resetToPackagedBundle() {
synchronized (commitLock) {
resetToPackagedBundleLocked();
}
}
private void resetToPackagedBundleLocked() {
// Invalidate any in-flight cold-start round before clearing state: a
// round committing under the same lock afterwards sees the new
// generation and drops its result.
resetGeneration.incrementAndGet();
StateCoreResult resetState = new StateCoreResult();
resetState.packageVersion = getPackageVersion();
resetState.buildTime = getBuildTime();
resetState.firstTime = false;
resetState.firstTimeOk = true;
// The cached check response still advertises the version this reset
// removes; drop it first so a failure aborts before the state wipe.
if (!clearNativeCheckCache()) {
throw new IllegalStateException("Failed to persist state for reset check cache");
}
SharedPreferences.Editor editor = sp.edit();
removeVersionScopedKeys(editor);
applyState(editor, resetState);
persistEditorOrThrow(editor, "reset to packaged bundle");
ignoreRollback = false;
Log.i(TAG, "Reset to packaged bundle");
cleanUp(0);
}
public static void setCustomInstanceManager(ReactInstanceManager instanceManager) {
synchronized (sLock) {
pendingReactInstanceManager = instanceManager;
if (sInstance != null) {
sInstance.reactInstanceManager = instanceManager;
}
}
}
public ReactInstanceManager getCustomReactInstanceManager() {
return reactInstanceManager;
}
/**
* Get singleton instance of UpdateContext
*/
public static UpdateContext getInstance(Context context) {
if (sInstance == null) {
synchronized (sLock) {
if (sInstance == null) {
sInstance = new UpdateContext(context.getApplicationContext());
}
}
}
return sInstance;
}
public static String getBundleUrl(Context context) {
return getInstance(context).getBundleUrl();
}
public static String getBundleUrl(Context context, String defaultAssetsUrl) {
return getInstance(context).getBundleUrl(defaultAssetsUrl);
}
public String getBundleUrl() {
return this.getBundleUrl((String) null);
}
public String getBundleUrl(String defaultAssetsUrl) {
isUsingBundleUrl = true;
String syncRolledBackVersion = null;
try {
// The whole resolution is one read-modify-write on the state
// (resolve, then possibly roll back missing bundles); see
// commitLock.
synchronized (commitLock) {
StateCoreResult currentState = getStateSnapshot();
StateCoreResult launchState = runStateCore(
STATE_OP_RESOLVE_LAUNCH,
currentState,
null,
ignoreRollback,
true
);
syncRolledBackVersion = launchState.rolledBackVersion;
if (launchState.didRollback) {
// The crash-protection rollback: the new version never called
// markSuccess. Keep this visible in release logs.
Log.e(TAG, "Version " + currentState.currentVersion
+ " was not marked as successful, rolling back to "
+ launchState.currentVersion);
}
if (launchState.didRollback || launchState.consumedFirstTime) {
SharedPreferences.Editor editor = sp.edit();
applyState(editor, launchState);
if (launchState.consumedFirstTime) {
editor.putBoolean(KEY_FIRST_LOAD_MARKED, true);
}
persistEditor(editor, "resolve launch");
}
if (launchState.consumedFirstTime) {
// bundleURL may be resolved multiple times in one process.
ignoreRollback = true;
}
String currentVersion = launchState.loadVersion;
if (currentVersion == null) {
return defaultAssetsUrl;
}
// Guard the rollback chain against cycles: a corrupted state returning
// an already-visited version would otherwise spin this loop forever on
// the main thread.
java.util.HashSet<String> visitedVersions = new java.util.HashSet<>();
while (currentVersion != null && visitedVersions.add(currentVersion)) {
File bundleFile = InstallRecord.bundleFile(new File(rootDir, currentVersion));
if (!bundleFile.exists()) {
Log.e(TAG, "Bundle version " + currentVersion + " not found.");
currentVersion = this.rollBack();
syncRolledBackVersion = rolledBackVersion();
continue;
}
launchVersion = currentVersion;
launchBundleSha256 = new String[] {
currentVersion, readBundleSha256(currentVersion)};
syncRolledBackVersion = rolledBackVersion();
return bundleFile.toString();
}
syncRolledBackVersion = rolledBackVersion();
return defaultAssetsUrl;
}
} finally {
// Even corrupted state or a state-core exception must not disable
// the next-launch rescue check. A null snapshot simply omits the
// rollback guard for this exceptional launch.
SyncCoordinator.schedule(this, syncRolledBackVersion);
}
}
/** Shared by JS and native hosts. Config replacement invalidates old native decisions. */
private static final java.util.concurrent.atomic.AtomicLong nativeConfigGeneration =
new java.util.concurrent.atomic.AtomicLong(0);
static long getNativeConfigGeneration() {
return nativeConfigGeneration.get();
}
void setNativeConfig(String config) {
synchronized (commitLock) {
// Canonical comparison (C1, FlowBridge.configsEquivalent):
// re-sending the same config with another key order or trailing
// slashes must not cancel an in-flight round or drop its cache.
boolean changed = !FlowBridge.configsEquivalent(
config, sp.getString(SyncCoordinator.KEY_CONFIG, null));
SharedPreferences.Editor editor = sp.edit();
if (changed) {
// Also invalidate on a failed persistence attempt: an older
// round must not commit over uncertain configuration state.
resetGeneration.incrementAndGet();
nativeConfigGeneration.incrementAndGet();
SyncCoordinator.recordJsRound(null);
if (!clearNativeCheckCache()) {
throw new IllegalStateException(
"Failed to persist state for configuration check cache");
}
}
// A native-only first launch needs a stable gray-release identity
// before JS initializes. Never replace an existing installation ID.
String uuid = sp.getString("uuid", null);
if (uuid == null || uuid.isEmpty()) {
editor.putString("uuid", java.util.UUID.randomUUID().toString());
}
editor.putString(SyncCoordinator.KEY_CONFIG, config);
// Persist even an equal value: a previous commit may have updated
// SharedPreferences memory but failed to write its file.
persistEditorOrThrow(editor, "persist configuration");
}
SyncCoordinator.onConfigured(this);
}
/**
* Native-decision generation, sampled/compared by the native check
* orchestrator: bumped by reset AND configuration replacement (see
* resetGeneration).
*/
static long getResetGeneration() {
return resetGeneration.get();
}
/**
* Commit everything a cold-start round persists β version info, the
* activation, the response cache β under one lock that first re-checks the
* reset generation. resetToPackagedBundle takes the same lock, so there is
* no compare-and-act window: either the whole round lands, or the reset
* wins and none of it does. Returns whether the round was committed.
*/
boolean commitSyncResult(
long expectedGeneration,
String hash,
String hashInfoJson,
boolean activate,
String responseCacheJson
) {
boolean switching = activate && hash != null;
if (!switching) {
return commitSyncResultState(
expectedGeneration, hash, hashInfoJson, false, responseCacheJson);
}
synchronized (versionFilesLock) {
verifySwitchTarget(hash);
return commitSyncResultState(
expectedGeneration, hash, hashInfoJson, true, responseCacheJson);
}
}
private boolean commitSyncResultState(
long expectedGeneration,
String hash,
String hashInfoJson,
boolean switching,
String responseCacheJson
) {
synchronized (commitLock) {
if (resetGeneration.get() != expectedGeneration) {
return false;
}
SharedPreferences.Editor editor = sp.edit();
if (hash != null && hashInfoJson != null) {
editor.putString("hash_" + hash, hashInfoJson);
}
if (switching) {
applyState(editor, computeSwitchState(hash));
}
// The round is over once its result lands: clear the
// interrupted-round breadcrumb in the same commit.
editor.remove(SyncCoordinator.KEY_ROUND_INCOMPLETE);
persistEditorOrThrow(editor, "commit sync result");
if (switching) {
ignoreRollback = false;
}
// Advisory cache, separate file: written after the state it
// describes, still under commitLock so a reset cannot interleave.
if (responseCacheJson != null
&& !checkCacheSp.edit()
.putString(SyncCoordinator.KEY_RESP_CACHE, responseCacheJson).commit()) {
Log.w(TAG, "Failed to persist native sync response cache");
}
return true;
}
}
/**
* bundleSha256 from the running version's install record (see
* InstallRecord); "" for the embedded bundle, a legacy install or an
* unreadable record. Exposed to JS for crash-report attribution.
*/
String currentBundleSha256(String hash) {
String[] cached = launchBundleSha256;
if (cached != null && cached[0].equals(hash)) {
return cached[1];
}
return readBundleSha256(hash);
}
private String readBundleSha256(String hash) {
if (hash == null || hash.isEmpty() || !UpdateFileUtils.isSafePathComponent(hash)) {
return "";
}
return InstallRecord.recordedBundleSha256(new File(rootDir, hash));
}
/** Raw native check response for JS (Β§10.3); null when absent. */
@Nullable
String getNativeCheckCache() {
return checkCacheSp.getString(SyncCoordinator.KEY_RESP_CACHE, null);
}
private boolean clearNativeCheckCache() {
if (!checkCacheSp.contains(SyncCoordinator.KEY_RESP_CACHE)) {
return true;
}
if (!checkCacheSp.edit().remove(SyncCoordinator.KEY_RESP_CACHE).commit()) {
Log.e(TAG, "Failed to clear native sync response cache");
return false;
}
return true;
}
boolean hasCompletedVersion(String hash) {
if (!UpdateFileUtils.isSafePathComponent(hash)) {
return false;
}
return InstallRecord.isInstalled(new File(rootDir, hash), hash);
}
private String rollBack() {
synchronized (commitLock) {
String rollingBack = getCurrentVersion();
StateCoreResult nextState = runStateOp(STATE_OP_ROLLBACK, null);
Log.e(TAG, "Rolling back version " + rollingBack
+ " to " + nextState.currentVersion);
SharedPreferences.Editor editor = sp.edit();
applyState(editor, nextState);
persistEditor(editor, "rollback");
return nextState.currentVersion;
}
}
/**
* The version this process is running from (null before getBundleUrl
* resolved one, or when the packaged bundle runs). Read by DownloadTask
* to refuse reinstalling that directory in place.
*/
@Nullable
static String runningVersion() {
UpdateContext instance = sInstance;
return instance == null ? null : instance.launchVersion;
}
interface CleanupAction {
void run(String[] keepNames);
}
/**
* Resolve keep names immediately before the cleanup acts, never when
* enqueued. The action runs under versionFilesLock, so it must stay cheap
* (DownloadTask only renames doomed entries into a trash directory and
* deletes them after this returns).
*/
void runCleanupWithLatestState(CleanupAction action) {
synchronized (versionFilesLock) {
final String[] keepNames;
synchronized (commitLock) {
keepNames = CleanupKeepNames.select(
sp.getString("currentVersion", null),
sp.getString("lastVersion", null),
launchVersion
);
}
action.run(keepNames);
}
}
/**
* Queues removal of every version entry older than maxAgeDays (0: all)
* except the keep names, which the queued task resolves at execution
* time so a later activation is protected too.
*/
private void cleanUp(int maxAgeDays) {
DownloadTaskParams params = new DownloadTaskParams();
params.type = DownloadTaskParams.TASK_TYPE_CLEANUP;
params.maxAgeDays = maxAgeDays;
params.unzipDirectory = rootDir;
enqueue(params);
}
}