@otakit/capacitor-updater 2.3.3 → 3.0.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/README.md +46 -5
- package/android/build.gradle +16 -0
- package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
- package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
- package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
- package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
- package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
- package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
- package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
- package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
- package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
- package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
- package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
- package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
- package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
- package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
- package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
- package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
- package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
- package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
- package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +262 -173
- package/dist/esm/definitions.d.ts +3 -3
- package/dist/esm/definitions.d.ts.map +1 -1
- package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
- package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
- package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
- package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
- package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
- package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
- package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
- package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
- package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
- package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
- package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
- package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
- package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
- package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
- package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
- package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
- package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
- package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
- package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
- package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
- package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
- package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
- package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
- package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
- package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
- package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
- package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
- package/package.json +7 -3
|
@@ -13,11 +13,8 @@ import com.getcapacitor.annotation.CapacitorPlugin;
|
|
|
13
13
|
import java.io.File;
|
|
14
14
|
import java.io.FileInputStream;
|
|
15
15
|
import java.io.FileOutputStream;
|
|
16
|
-
import java.io.InputStream;
|
|
17
|
-
import java.net.HttpURLConnection;
|
|
18
16
|
import java.net.URL;
|
|
19
17
|
import java.nio.charset.StandardCharsets;
|
|
20
|
-
import java.security.MessageDigest;
|
|
21
18
|
import java.util.concurrent.CountDownLatch;
|
|
22
19
|
import java.util.concurrent.ExecutorService;
|
|
23
20
|
import java.util.concurrent.Executors;
|
|
@@ -100,9 +97,13 @@ public class UpdaterPlugin extends Plugin {
|
|
|
100
97
|
|
|
101
98
|
private BundleStore store;
|
|
102
99
|
private UpdaterCoordinator coordinator;
|
|
103
|
-
private
|
|
100
|
+
private final UpdateOwner updateOwner;
|
|
101
|
+
private final DocumentReadyBridge documentReadyBridge = new DocumentReadyBridge();
|
|
102
|
+
private final ForegroundDeadline trialDeadline;
|
|
104
103
|
|
|
105
104
|
private int appReadyTimeoutMs = 10_000;
|
|
105
|
+
private boolean storageReady;
|
|
106
|
+
private volatile String lifecycleState = "unknown";
|
|
106
107
|
private boolean allowInsecureUrls = false;
|
|
107
108
|
private Policy launchPolicy = Policy.APPLY_STAGED;
|
|
108
109
|
private Policy resumePolicy = Policy.SHADOW;
|
|
@@ -126,6 +127,21 @@ public class UpdaterPlugin extends Plugin {
|
|
|
126
127
|
java.util.regex.Pattern.compile("^[A-Za-z0-9._-]{1,64}$");
|
|
127
128
|
private UpdaterCoordinator.StartupPreparation pendingStartupPreparation;
|
|
128
129
|
|
|
130
|
+
public UpdaterPlugin() {
|
|
131
|
+
this.trialDeadline = new ForegroundDeadline(mainHandler);
|
|
132
|
+
this.updateOwner = new UpdateOwner(() -> bridge != null && bridge.getWebView() != null);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
UpdaterPlugin(ForegroundDeadline trialDeadline) {
|
|
136
|
+
this.trialDeadline = trialDeadline;
|
|
137
|
+
this.updateOwner = new UpdateOwner(() -> bridge != null && bridge.getWebView() != null);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
UpdaterPlugin(ForegroundDeadline trialDeadline, UpdateOwner updateOwner) {
|
|
141
|
+
this.trialDeadline = trialDeadline;
|
|
142
|
+
this.updateOwner = updateOwner;
|
|
143
|
+
}
|
|
144
|
+
|
|
129
145
|
@Override
|
|
130
146
|
public void load() {
|
|
131
147
|
super.load();
|
|
@@ -151,6 +167,7 @@ public class UpdaterPlugin extends Plugin {
|
|
|
151
167
|
);
|
|
152
168
|
this.cdnUrl = resolveCdnUrl(getConfig().getString("cdnUrl"), System.getenv("OTAKIT_CDN_URL"));
|
|
153
169
|
this.appId = getConfig().getString("appId");
|
|
170
|
+
DeviceEventClient.resume(new File(getContext().getNoBackupFilesDir(), "otakit-events"));
|
|
154
171
|
this.channel = trimToNull(getConfig().getString("channel"));
|
|
155
172
|
this.runtimeVersion = trimToNull(getConfig().getString("runtimeVersion"));
|
|
156
173
|
this.store = new BundleStore(getContext(), builtinVersion, nativeBuild, this.runtimeVersion);
|
|
@@ -160,37 +177,7 @@ public class UpdaterPlugin extends Plugin {
|
|
|
160
177
|
this.resumePolicy = resolvePolicy(getConfig().getString("resumePolicy"), Policy.SHADOW);
|
|
161
178
|
this.runtimePolicy = resolvePolicy(getConfig().getString("runtimePolicy"), Policy.IMMEDIATE);
|
|
162
179
|
|
|
163
|
-
|
|
164
|
-
org.json.JSONArray rawKeys = getConfig().getConfigJSON().optJSONArray("manifestKeys");
|
|
165
|
-
if (rawKeys != null && rawKeys.length() > 0) {
|
|
166
|
-
for (int i = 0; i < rawKeys.length(); i++) {
|
|
167
|
-
org.json.JSONObject entry = rawKeys.optJSONObject(i);
|
|
168
|
-
if (entry == null) {
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
String kid = entry.optString("kid", null);
|
|
172
|
-
String keyBase64 = entry.optString("key", null);
|
|
173
|
-
if (kid != null && keyBase64 != null) {
|
|
174
|
-
byte[] keyBytes = android.util.Base64.decode(keyBase64, android.util.Base64.DEFAULT);
|
|
175
|
-
manifestKeys.add(new ManifestVerifier.KeyEntry(kid, keyBytes));
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
if (manifestKeys.isEmpty()) {
|
|
179
|
-
android.util.Log.e(
|
|
180
|
-
"OtaKit",
|
|
181
|
-
"manifestKeys configured but all entries are invalid. Manifest verification will reject all updates."
|
|
182
|
-
);
|
|
183
|
-
manifestKeys.add(new ManifestVerifier.KeyEntry("_invalid_", new byte[0]));
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
} catch (Exception e) {
|
|
187
|
-
android.util.Log.e(
|
|
188
|
-
"OtaKit",
|
|
189
|
-
"Failed to parse manifestKeys. Manifest verification will reject all updates.",
|
|
190
|
-
e
|
|
191
|
-
);
|
|
192
|
-
manifestKeys.add(new ManifestVerifier.KeyEntry("_invalid_", new byte[0]));
|
|
193
|
-
}
|
|
180
|
+
manifestKeys = ManifestKeyConfig.parse(getConfig().getConfigJSON());
|
|
194
181
|
|
|
195
182
|
if (manifestKeys.isEmpty() && HostedManifestKeys.matchesManagedManifestUrl(cdnUrl)) {
|
|
196
183
|
manifestKeys.addAll(HostedManifestKeys.createDefaultKeys());
|
|
@@ -231,13 +218,17 @@ public class UpdaterPlugin extends Plugin {
|
|
|
231
218
|
this.appReadyTimeoutMs = Math.max(1000, getConfig().getInt("appReadyTimeout", 10_000));
|
|
232
219
|
this.checkIntervalMs = getConfig().getInt("checkInterval", 600_000);
|
|
233
220
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
221
|
+
try {
|
|
222
|
+
pruneIncompatibleBundles();
|
|
223
|
+
UpdaterCoordinator.StartupPreparation startup = coordinator.normalizeStartupState(
|
|
224
|
+
this::isBundleUsable
|
|
225
|
+
);
|
|
226
|
+
cleanupInBackground(startup.cleanupBundleIds);
|
|
227
|
+
pendingStartupPreparation = startup;
|
|
228
|
+
storageReady = true;
|
|
229
|
+
} catch (Exception error) {
|
|
230
|
+
android.util.Log.e("OtaKit", "Cannot persist startup recovery; keeping builtin", error);
|
|
231
|
+
}
|
|
241
232
|
}
|
|
242
233
|
|
|
243
234
|
@Override
|
|
@@ -249,6 +240,12 @@ public class UpdaterPlugin extends Plugin {
|
|
|
249
240
|
@Override
|
|
250
241
|
protected void handleOnResume() {
|
|
251
242
|
super.handleOnResume();
|
|
243
|
+
lifecycleState = "foreground";
|
|
244
|
+
trialDeadline.setForeground(true);
|
|
245
|
+
if (bridge != null) DeviceEventClient.resume(
|
|
246
|
+
new File(getContext().getNoBackupFilesDir(), "otakit-events")
|
|
247
|
+
);
|
|
248
|
+
if (!storageReady) return;
|
|
252
249
|
if (coldStartInProgress) {
|
|
253
250
|
coldStartInProgress = false;
|
|
254
251
|
return;
|
|
@@ -256,6 +253,20 @@ public class UpdaterPlugin extends Plugin {
|
|
|
256
253
|
handleResume();
|
|
257
254
|
}
|
|
258
255
|
|
|
256
|
+
@Override
|
|
257
|
+
protected void handleOnPause() {
|
|
258
|
+
lifecycleState = "background";
|
|
259
|
+
trialDeadline.setForeground(false);
|
|
260
|
+
super.handleOnPause();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
@Override
|
|
264
|
+
protected void handleOnDestroy() {
|
|
265
|
+
updateOwner.close();
|
|
266
|
+
trialDeadline.cancel();
|
|
267
|
+
super.handleOnDestroy();
|
|
268
|
+
}
|
|
269
|
+
|
|
259
270
|
private boolean shouldSkipCheckInterval() {
|
|
260
271
|
if (checkIntervalMs <= 0) return false;
|
|
261
272
|
long lastCheck = store.getPrefs().getLong(KEY_LAST_CHECK_TIMESTAMP, 0);
|
|
@@ -285,9 +296,14 @@ public class UpdaterPlugin extends Plugin {
|
|
|
285
296
|
|
|
286
297
|
if (startup.activationPath != null && !startup.activationPath.isEmpty()) {
|
|
287
298
|
try {
|
|
299
|
+
if (startup.trial != null) installReadinessBridge(startup.trial);
|
|
288
300
|
applyServerBasePathSynchronously(startup.activationPath);
|
|
289
301
|
} catch (Exception e) {
|
|
290
302
|
android.util.Log.w("OtaKit", "startup activation failed", e);
|
|
303
|
+
if (startup.trial != null) {
|
|
304
|
+
rollbackCurrentBundle(startup.trial, "activation_failed");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
291
307
|
}
|
|
292
308
|
}
|
|
293
309
|
|
|
@@ -295,8 +311,8 @@ public class UpdaterPlugin extends Plugin {
|
|
|
295
311
|
sendDeviceEvent(startup.eventPayload);
|
|
296
312
|
}
|
|
297
313
|
|
|
298
|
-
if (startup.
|
|
299
|
-
scheduleTrialTimeout(startup.
|
|
314
|
+
if (startup.trial != null) {
|
|
315
|
+
scheduleTrialTimeout(startup.trial);
|
|
300
316
|
} else {
|
|
301
317
|
cancelTrialTimeout();
|
|
302
318
|
}
|
|
@@ -441,7 +457,10 @@ public class UpdaterPlugin extends Plugin {
|
|
|
441
457
|
}
|
|
442
458
|
executor.execute(() -> {
|
|
443
459
|
try {
|
|
460
|
+
ensureOwnerActive();
|
|
444
461
|
operation.run();
|
|
462
|
+
} catch (java.util.concurrent.CancellationException ignored) {
|
|
463
|
+
// The owning bridge went away; this is not a failed update attempt.
|
|
445
464
|
} catch (Exception e) {
|
|
446
465
|
android.util.Log.w("OtaKit", label + " failed", e);
|
|
447
466
|
} finally {
|
|
@@ -572,18 +591,24 @@ public class UpdaterPlugin extends Plugin {
|
|
|
572
591
|
|
|
573
592
|
@PluginMethod
|
|
574
593
|
public void notifyAppReady(PluginCall call) {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
594
|
+
mainHandler.post(() -> {
|
|
595
|
+
try {
|
|
596
|
+
UpdaterCoordinator.NotifyReadyPreparation preparation = updateOwner.run(() ->
|
|
597
|
+
coordinator.prepareNotifyAppReady(call.getString("_otakitActivationId"))
|
|
598
|
+
);
|
|
599
|
+
cleanupInBackground(preparation.cleanupBundleIds);
|
|
600
|
+
if (preparation.eventPayload != null) {
|
|
601
|
+
cancelTrialTimeout();
|
|
602
|
+
sendDeviceEvent(preparation.eventPayload);
|
|
603
|
+
JSObject appliedData = new JSObject();
|
|
604
|
+
appliedData.put("bundle", store.getCurrentBundle().toJSObject());
|
|
605
|
+
emitEvent("updateApplied", appliedData);
|
|
606
|
+
}
|
|
607
|
+
call.resolve();
|
|
608
|
+
} catch (Exception error) {
|
|
609
|
+
call.reject("Could not persist update readiness: " + error.getMessage(), error);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
587
612
|
}
|
|
588
613
|
|
|
589
614
|
@PluginMethod
|
|
@@ -658,17 +683,38 @@ public class UpdaterPlugin extends Plugin {
|
|
|
658
683
|
throw new IllegalStateException("Missing appId in plugin config");
|
|
659
684
|
}
|
|
660
685
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
686
|
+
String checkId = "check-" + java.util.UUID.randomUUID();
|
|
687
|
+
return CheckFailure.observe(
|
|
688
|
+
() -> {
|
|
689
|
+
ManifestClient.LatestManifest latest = ManifestClient.fetchLatest(
|
|
690
|
+
cdnUrl,
|
|
691
|
+
appId,
|
|
692
|
+
channel,
|
|
693
|
+
runtimeVersion,
|
|
694
|
+
allowInsecureUrls,
|
|
695
|
+
manifestKeys
|
|
696
|
+
);
|
|
697
|
+
ensureOwnerActive();
|
|
698
|
+
return latest;
|
|
699
|
+
},
|
|
700
|
+
failure -> {
|
|
701
|
+
ensureOwnerActive();
|
|
702
|
+
sendDeviceEvent(
|
|
703
|
+
"check_error",
|
|
704
|
+
null,
|
|
705
|
+
runtimeVersion,
|
|
706
|
+
channel,
|
|
707
|
+
null,
|
|
708
|
+
failure.detail,
|
|
709
|
+
checkId,
|
|
710
|
+
failure.phase
|
|
711
|
+
);
|
|
712
|
+
}
|
|
668
713
|
);
|
|
669
714
|
}
|
|
670
715
|
|
|
671
716
|
private CheckResolution checkLatest(boolean respectInterval, String channel) throws Exception {
|
|
717
|
+
ensureOwnerActive();
|
|
672
718
|
String targetChannel = resolveTargetChannel(channel);
|
|
673
719
|
if (respectInterval && shouldSkipCheckInterval()) {
|
|
674
720
|
android.util.Log.d("OtaKit", "Skipping resume check: checkInterval has not elapsed");
|
|
@@ -750,6 +796,17 @@ public class UpdaterPlugin extends Plugin {
|
|
|
750
796
|
String targetChannel
|
|
751
797
|
) throws Exception {
|
|
752
798
|
if (!isCompatibleRuntime(latest.runtimeVersion)) {
|
|
799
|
+
ensureOwnerActive();
|
|
800
|
+
sendDeviceEvent(
|
|
801
|
+
"check_error",
|
|
802
|
+
latest.version,
|
|
803
|
+
runtimeVersion,
|
|
804
|
+
targetChannel,
|
|
805
|
+
latest.releaseId,
|
|
806
|
+
"runtime_mismatch",
|
|
807
|
+
"check-" + java.util.UUID.randomUUID(),
|
|
808
|
+
"check"
|
|
809
|
+
);
|
|
753
810
|
throw new IllegalStateException(
|
|
754
811
|
"Manifest runtimeVersion does not match the installed app runtime"
|
|
755
812
|
);
|
|
@@ -785,15 +842,7 @@ public class UpdaterPlugin extends Plugin {
|
|
|
785
842
|
}
|
|
786
843
|
|
|
787
844
|
private boolean isExpiredURLError(Exception e) {
|
|
788
|
-
|
|
789
|
-
if (msg == null) return false;
|
|
790
|
-
msg = msg.toLowerCase();
|
|
791
|
-
return (
|
|
792
|
-
msg.contains("403") ||
|
|
793
|
-
msg.contains("410") ||
|
|
794
|
-
msg.contains("forbidden") ||
|
|
795
|
-
msg.contains("expired")
|
|
796
|
-
);
|
|
845
|
+
return DownloadRetry.isExpiredUrlFailure(e);
|
|
797
846
|
}
|
|
798
847
|
|
|
799
848
|
private BundleInfo downloadAndStage(
|
|
@@ -806,6 +855,9 @@ public class UpdaterPlugin extends Plugin {
|
|
|
806
855
|
String releaseId,
|
|
807
856
|
ManifestClient.ManifestEncryption encryption
|
|
808
857
|
) throws Exception {
|
|
858
|
+
ensureOwnerActive();
|
|
859
|
+
String installationId = BundleStore.newInstallationId();
|
|
860
|
+
String phase = "admission";
|
|
809
861
|
// Check disk space before downloading
|
|
810
862
|
if (expectedSize > 0) {
|
|
811
863
|
// zip + extracted + buffer; encrypted bundles keep an extra decrypted
|
|
@@ -820,7 +872,9 @@ public class UpdaterPlugin extends Plugin {
|
|
|
820
872
|
runtimeVersion,
|
|
821
873
|
channel,
|
|
822
874
|
releaseId,
|
|
823
|
-
"insufficient_disk_space"
|
|
875
|
+
"insufficient_disk_space",
|
|
876
|
+
installationId,
|
|
877
|
+
phase
|
|
824
878
|
);
|
|
825
879
|
emitEvent(
|
|
826
880
|
"downloadFailed",
|
|
@@ -835,15 +889,22 @@ public class UpdaterPlugin extends Plugin {
|
|
|
835
889
|
File extractedDirectory = null;
|
|
836
890
|
|
|
837
891
|
try {
|
|
892
|
+
phase = "transfer";
|
|
838
893
|
downloadedZip = downloadZip(url);
|
|
894
|
+
ensureOwnerActive();
|
|
839
895
|
// The manifest sha256 covers the downloaded object as-is — the
|
|
840
896
|
// ciphertext when the bundle is encrypted.
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
897
|
+
phase = "integrity";
|
|
898
|
+
HashUtils.verifyDownload(
|
|
899
|
+
downloadedZip,
|
|
900
|
+
expectedSha256,
|
|
901
|
+
expectedSize > 0 ? expectedSize : -1,
|
|
902
|
+
"bundle"
|
|
903
|
+
);
|
|
844
904
|
|
|
845
905
|
File zipToExtract = downloadedZip;
|
|
846
906
|
if (encryption != null) {
|
|
907
|
+
phase = "decrypt";
|
|
847
908
|
byte[] kek = bundleKeys.get(encryption.kid);
|
|
848
909
|
if (kek == null) {
|
|
849
910
|
throw new IllegalStateException("no matching bundle key (kid " + encryption.kid + ")");
|
|
@@ -866,6 +927,7 @@ public class UpdaterPlugin extends Plugin {
|
|
|
866
927
|
zipToExtract = decryptedZip;
|
|
867
928
|
}
|
|
868
929
|
|
|
930
|
+
phase = "extract";
|
|
869
931
|
extractedDirectory = new File(
|
|
870
932
|
getContext().getCacheDir(),
|
|
871
933
|
"otakit-extract-" + System.currentTimeMillis()
|
|
@@ -877,10 +939,11 @@ public class UpdaterPlugin extends Plugin {
|
|
|
877
939
|
zipUtils.extractSecurely(zipToExtract, extractedDirectory);
|
|
878
940
|
File bundleRoot = resolveBundleRoot(extractedDirectory);
|
|
879
941
|
|
|
880
|
-
|
|
942
|
+
phase = "install";
|
|
943
|
+
String bundleId = installationId;
|
|
881
944
|
File destination = coordinator.bundleDirectory(bundleId);
|
|
882
945
|
if (destination.exists()) {
|
|
883
|
-
|
|
946
|
+
throw new IllegalStateException("Bundle installation directory already exists");
|
|
884
947
|
}
|
|
885
948
|
moveDirectory(bundleRoot, destination);
|
|
886
949
|
|
|
@@ -895,22 +958,35 @@ public class UpdaterPlugin extends Plugin {
|
|
|
895
958
|
channel,
|
|
896
959
|
releaseId
|
|
897
960
|
);
|
|
898
|
-
|
|
961
|
+
phase = "stage";
|
|
962
|
+
java.util.List<String> cleanupBundleIds = stageOwnedBundle(info);
|
|
899
963
|
coordinator.cleanupBundles(cleanupBundleIds);
|
|
900
964
|
|
|
901
|
-
sendDeviceEvent(
|
|
965
|
+
sendDeviceEvent(
|
|
966
|
+
"downloaded",
|
|
967
|
+
version,
|
|
968
|
+
runtimeVersion,
|
|
969
|
+
channel,
|
|
970
|
+
releaseId,
|
|
971
|
+
null,
|
|
972
|
+
installationId,
|
|
973
|
+
phase
|
|
974
|
+
);
|
|
902
975
|
JSObject stagedData = new JSObject();
|
|
903
976
|
stagedData.put("bundle", info.toJSObject());
|
|
904
977
|
emitEvent("updateStaged", stagedData);
|
|
905
978
|
return info;
|
|
906
979
|
} catch (Exception e) {
|
|
980
|
+
if (e instanceof java.util.concurrent.CancellationException) throw e;
|
|
907
981
|
sendDeviceEvent(
|
|
908
982
|
"download_error",
|
|
909
983
|
version,
|
|
910
984
|
runtimeVersion,
|
|
911
985
|
channel,
|
|
912
986
|
releaseId,
|
|
913
|
-
e.getMessage()
|
|
987
|
+
e.getMessage(),
|
|
988
|
+
installationId,
|
|
989
|
+
phase
|
|
914
990
|
);
|
|
915
991
|
emitEvent(
|
|
916
992
|
"downloadFailed",
|
|
@@ -918,6 +994,9 @@ public class UpdaterPlugin extends Plugin {
|
|
|
918
994
|
);
|
|
919
995
|
throw e;
|
|
920
996
|
} finally {
|
|
997
|
+
if (installationId != null) coordinator.cleanupBundles(
|
|
998
|
+
java.util.Collections.singletonList(installationId)
|
|
999
|
+
);
|
|
921
1000
|
if (downloadedZip != null && downloadedZip.exists()) {
|
|
922
1001
|
//noinspection ResultOfMethodCallIgnored
|
|
923
1002
|
downloadedZip.delete();
|
|
@@ -935,35 +1014,7 @@ public class UpdaterPlugin extends Plugin {
|
|
|
935
1014
|
}
|
|
936
1015
|
|
|
937
1016
|
private File downloadZip(URL url) throws Exception {
|
|
938
|
-
|
|
939
|
-
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
|
940
|
-
try {
|
|
941
|
-
connection.setRequestMethod("GET");
|
|
942
|
-
connection.setConnectTimeout(15_000);
|
|
943
|
-
connection.setReadTimeout(60_000);
|
|
944
|
-
|
|
945
|
-
int status = connection.getResponseCode();
|
|
946
|
-
if (status < 200 || status >= 300) {
|
|
947
|
-
throw new IllegalStateException("Download failed with HTTP " + status);
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
File destination = File.createTempFile("otakit-", ".zip", getContext().getCacheDir());
|
|
951
|
-
|
|
952
|
-
try (
|
|
953
|
-
InputStream input = connection.getInputStream();
|
|
954
|
-
FileOutputStream output = new FileOutputStream(destination)
|
|
955
|
-
) {
|
|
956
|
-
byte[] buffer = new byte[8192];
|
|
957
|
-
int read;
|
|
958
|
-
while ((read = input.read(buffer)) > 0) {
|
|
959
|
-
output.write(buffer, 0, read);
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
return destination;
|
|
964
|
-
} finally {
|
|
965
|
-
connection.disconnect();
|
|
966
|
-
}
|
|
1017
|
+
return FileDownloader.download(url, getContext().getCacheDir(), allowInsecureUrls);
|
|
967
1018
|
}
|
|
968
1019
|
|
|
969
1020
|
private File resolveBundleRoot(File extractedDirectory) throws Exception {
|
|
@@ -984,20 +1035,54 @@ public class UpdaterPlugin extends Plugin {
|
|
|
984
1035
|
}
|
|
985
1036
|
|
|
986
1037
|
private boolean applyStaged() throws Exception {
|
|
1038
|
+
java.util.concurrent.atomic.AtomicBoolean applied =
|
|
1039
|
+
new java.util.concurrent.atomic.AtomicBoolean();
|
|
1040
|
+
runOnMainSynchronously(() -> applied.set(updateOwner.run(this::applyStagedOnMain)));
|
|
1041
|
+
return applied.get();
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
private java.util.List<String> stageOwnedBundle(BundleInfo bundle) throws Exception {
|
|
1045
|
+
AtomicReference<java.util.List<String>> cleanup = new AtomicReference<>();
|
|
1046
|
+
runOnMainSynchronously(() ->
|
|
1047
|
+
cleanup.set(updateOwner.run(() -> coordinator.stageDownloadedBundle(bundle)))
|
|
1048
|
+
);
|
|
1049
|
+
return cleanup.get();
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
private void ensureOwnerActive() throws Exception {
|
|
1053
|
+
runOnMainSynchronously(() -> updateOwner.run(() -> null));
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private void cleanupInBackground(java.util.List<String> ids) {
|
|
1057
|
+
if (!ids.isEmpty()) executor.execute(() -> coordinator.cleanupBundles(ids));
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
private void installReadinessBridge(UpdaterCoordinator.Trial trial) {
|
|
1061
|
+
documentReadyBridge.install(bridge.getWebView(), bridge.getAppUrl(), trial.activationId);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
private boolean applyStagedOnMain() throws Exception {
|
|
1065
|
+
DocumentReadyBridge.preflight(bridge.getWebView(), bridge.getAppUrl());
|
|
987
1066
|
UpdaterCoordinator.ApplyPreparation preparation = coordinator.prepareApplyStaged(
|
|
988
1067
|
this::isCompatibleRuntime,
|
|
989
1068
|
this::isBundleUsable
|
|
990
1069
|
);
|
|
991
|
-
|
|
1070
|
+
cleanupInBackground(preparation.cleanupBundleIds);
|
|
992
1071
|
if (!preparation.didApply()) {
|
|
993
1072
|
return false;
|
|
994
1073
|
}
|
|
995
1074
|
|
|
996
|
-
|
|
1075
|
+
try {
|
|
1076
|
+
if (preparation.trial != null) installReadinessBridge(preparation.trial);
|
|
1077
|
+
applyServerBasePathSynchronously(preparation.activationPath);
|
|
1078
|
+
} catch (Exception error) {
|
|
1079
|
+
if (preparation.trial != null) rollbackCurrentBundle(preparation.trial, "activation_failed");
|
|
1080
|
+
throw error;
|
|
1081
|
+
}
|
|
997
1082
|
|
|
998
1083
|
cancelTrialTimeout();
|
|
999
|
-
if (preparation.
|
|
1000
|
-
scheduleTrialTimeout(preparation.
|
|
1084
|
+
if (preparation.trial != null) {
|
|
1085
|
+
scheduleTrialTimeout(preparation.trial);
|
|
1001
1086
|
}
|
|
1002
1087
|
return true;
|
|
1003
1088
|
}
|
|
@@ -1008,33 +1093,33 @@ public class UpdaterPlugin extends Plugin {
|
|
|
1008
1093
|
}
|
|
1009
1094
|
}
|
|
1010
1095
|
|
|
1011
|
-
private void scheduleTrialTimeout(
|
|
1012
|
-
|
|
1013
|
-
trialTimeoutRunnable = () -> {
|
|
1014
|
-
if (coordinator.isCurrentTrialBundle(bundleId)) {
|
|
1015
|
-
rollbackCurrentBundle("notify_timeout");
|
|
1016
|
-
}
|
|
1017
|
-
};
|
|
1018
|
-
mainHandler.postDelayed(trialTimeoutRunnable, appReadyTimeoutMs);
|
|
1096
|
+
private void scheduleTrialTimeout(UpdaterCoordinator.Trial trial) {
|
|
1097
|
+
trialDeadline.start(appReadyTimeoutMs, () -> rollbackCurrentBundle(trial, "notify_timeout"));
|
|
1019
1098
|
}
|
|
1020
1099
|
|
|
1021
1100
|
private void cancelTrialTimeout() {
|
|
1022
|
-
|
|
1023
|
-
mainHandler.removeCallbacks(trialTimeoutRunnable);
|
|
1024
|
-
trialTimeoutRunnable = null;
|
|
1025
|
-
}
|
|
1101
|
+
trialDeadline.cancel();
|
|
1026
1102
|
}
|
|
1027
1103
|
|
|
1028
|
-
private void rollbackCurrentBundle(String reason) {
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1104
|
+
private void rollbackCurrentBundle(UpdaterCoordinator.Trial expectedTrial, String reason) {
|
|
1105
|
+
UpdaterCoordinator.RollbackPreparation preparation;
|
|
1106
|
+
try {
|
|
1107
|
+
preparation = updateOwner.run(() ->
|
|
1108
|
+
coordinator.prepareRollback(expectedTrial, reason, this::isBundleUsable)
|
|
1109
|
+
);
|
|
1110
|
+
} catch (java.util.concurrent.CancellationException ignored) {
|
|
1111
|
+
cancelTrialTimeout();
|
|
1112
|
+
return;
|
|
1113
|
+
} catch (Exception error) {
|
|
1114
|
+
android.util.Log.e("OtaKit", "Cannot persist rollback", error);
|
|
1115
|
+
scheduleTrialTimeout(expectedTrial);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1034
1118
|
if (!preparation.didRollback) {
|
|
1035
1119
|
return;
|
|
1036
1120
|
}
|
|
1037
|
-
|
|
1121
|
+
cancelTrialTimeout();
|
|
1122
|
+
cleanupInBackground(preparation.cleanupBundleIds);
|
|
1038
1123
|
if (preparation.eventPayload != null) {
|
|
1039
1124
|
sendDeviceEvent(preparation.eventPayload);
|
|
1040
1125
|
emitEvent(
|
|
@@ -1156,6 +1241,9 @@ public class UpdaterPlugin extends Plugin {
|
|
|
1156
1241
|
*/
|
|
1157
1242
|
private BundleInfo assembleAndStage(ManifestClient.LatestManifest manifest, String targetChannel)
|
|
1158
1243
|
throws Exception {
|
|
1244
|
+
ensureOwnerActive();
|
|
1245
|
+
String installationId = BundleStore.newInstallationId();
|
|
1246
|
+
String phase = "admission";
|
|
1159
1247
|
// Same conservative disk-space guard as the zip path.
|
|
1160
1248
|
if (manifest.size > 0) {
|
|
1161
1249
|
long requiredSpace = (long) (manifest.size * 2.5);
|
|
@@ -1166,7 +1254,9 @@ public class UpdaterPlugin extends Plugin {
|
|
|
1166
1254
|
manifest.runtimeVersion,
|
|
1167
1255
|
targetChannel,
|
|
1168
1256
|
manifest.releaseId,
|
|
1169
|
-
"insufficient_disk_space"
|
|
1257
|
+
"insufficient_disk_space",
|
|
1258
|
+
installationId,
|
|
1259
|
+
phase
|
|
1170
1260
|
);
|
|
1171
1261
|
emitEvent(
|
|
1172
1262
|
"downloadFailed",
|
|
@@ -1188,6 +1278,7 @@ public class UpdaterPlugin extends Plugin {
|
|
|
1188
1278
|
);
|
|
1189
1279
|
|
|
1190
1280
|
try {
|
|
1281
|
+
phase = "delta";
|
|
1191
1282
|
if (manifest.files == null || manifest.files.isEmpty()) {
|
|
1192
1283
|
throw new IllegalStateException("Delta manifest is missing its file list");
|
|
1193
1284
|
}
|
|
@@ -1200,10 +1291,11 @@ public class UpdaterPlugin extends Plugin {
|
|
|
1200
1291
|
assembler.seedFromBuiltinIfNeeded(getContext(), BUILTIN_ASSET_PATH, store.getNativeBuild());
|
|
1201
1292
|
assembler.assemble(manifest.files, assembleDirectory, getContext());
|
|
1202
1293
|
|
|
1203
|
-
|
|
1294
|
+
phase = "install";
|
|
1295
|
+
String bundleId = installationId;
|
|
1204
1296
|
File destination = coordinator.bundleDirectory(bundleId);
|
|
1205
1297
|
if (destination.exists()) {
|
|
1206
|
-
|
|
1298
|
+
throw new IllegalStateException("Bundle installation directory already exists");
|
|
1207
1299
|
}
|
|
1208
1300
|
moveDirectory(assembleDirectory, destination);
|
|
1209
1301
|
|
|
@@ -1233,7 +1325,8 @@ public class UpdaterPlugin extends Plugin {
|
|
|
1233
1325
|
targetChannel,
|
|
1234
1326
|
manifest.releaseId
|
|
1235
1327
|
);
|
|
1236
|
-
|
|
1328
|
+
phase = "stage";
|
|
1329
|
+
java.util.List<String> cleanupBundleIds = stageOwnedBundle(info);
|
|
1237
1330
|
coordinator.cleanupBundles(cleanupBundleIds);
|
|
1238
1331
|
|
|
1239
1332
|
pruneDeltaCache(assembler);
|
|
@@ -1244,20 +1337,25 @@ public class UpdaterPlugin extends Plugin {
|
|
|
1244
1337
|
manifest.runtimeVersion,
|
|
1245
1338
|
targetChannel,
|
|
1246
1339
|
manifest.releaseId,
|
|
1247
|
-
null
|
|
1340
|
+
null,
|
|
1341
|
+
installationId,
|
|
1342
|
+
phase
|
|
1248
1343
|
);
|
|
1249
1344
|
JSObject stagedData = new JSObject();
|
|
1250
1345
|
stagedData.put("bundle", info.toJSObject());
|
|
1251
1346
|
emitEvent("updateStaged", stagedData);
|
|
1252
1347
|
return info;
|
|
1253
1348
|
} catch (Exception e) {
|
|
1349
|
+
if (e instanceof java.util.concurrent.CancellationException) throw e;
|
|
1254
1350
|
sendDeviceEvent(
|
|
1255
1351
|
"download_error",
|
|
1256
1352
|
manifest.version,
|
|
1257
1353
|
manifest.runtimeVersion,
|
|
1258
1354
|
targetChannel,
|
|
1259
1355
|
manifest.releaseId,
|
|
1260
|
-
e.getMessage()
|
|
1356
|
+
e.getMessage(),
|
|
1357
|
+
installationId,
|
|
1358
|
+
phase
|
|
1261
1359
|
);
|
|
1262
1360
|
emitEvent(
|
|
1263
1361
|
"downloadFailed",
|
|
@@ -1271,6 +1369,9 @@ public class UpdaterPlugin extends Plugin {
|
|
|
1271
1369
|
);
|
|
1272
1370
|
throw e;
|
|
1273
1371
|
} finally {
|
|
1372
|
+
if (installationId != null) coordinator.cleanupBundles(
|
|
1373
|
+
java.util.Collections.singletonList(installationId)
|
|
1374
|
+
);
|
|
1274
1375
|
if (assembleDirectory.exists()) {
|
|
1275
1376
|
try {
|
|
1276
1377
|
deleteRecursively(assembleDirectory);
|
|
@@ -1374,36 +1475,6 @@ public class UpdaterPlugin extends Plugin {
|
|
|
1374
1475
|
}
|
|
1375
1476
|
}
|
|
1376
1477
|
|
|
1377
|
-
private String buildBundleId(String version, String releaseId, String sha256) throws Exception {
|
|
1378
|
-
String trimmed = version == null ? "" : version.trim();
|
|
1379
|
-
String normalized = trimmed.replaceAll("[^A-Za-z0-9._-]", "-");
|
|
1380
|
-
normalized = normalized.replaceAll("-{2,}", "-");
|
|
1381
|
-
normalized = normalized.replaceAll("^[\\-.]+|[\\-.]+$", "");
|
|
1382
|
-
if (normalized.isEmpty()) {
|
|
1383
|
-
normalized = "bundle";
|
|
1384
|
-
}
|
|
1385
|
-
if (normalized.length() > 64) {
|
|
1386
|
-
normalized = normalized.substring(0, 64);
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
String identitySource = trimToNull(releaseId);
|
|
1390
|
-
if (identitySource == null) {
|
|
1391
|
-
identitySource = trimToNull(sha256);
|
|
1392
|
-
}
|
|
1393
|
-
if (identitySource == null) {
|
|
1394
|
-
identitySource = trimmed;
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
1398
|
-
byte[] hash = digest.digest(identitySource.getBytes(StandardCharsets.UTF_8));
|
|
1399
|
-
StringBuilder suffix = new StringBuilder();
|
|
1400
|
-
for (int i = 0; i < 6; i++) {
|
|
1401
|
-
suffix.append(String.format("%02x", hash[i]));
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
return normalized + "-" + suffix;
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
1478
|
private String resolveIngestUrl(String configured, String env) {
|
|
1408
1479
|
String configuredValue = trimToNull(configured);
|
|
1409
1480
|
if (configuredValue != null) {
|
|
@@ -1525,6 +1596,19 @@ public class UpdaterPlugin extends Plugin {
|
|
|
1525
1596
|
String channel,
|
|
1526
1597
|
String releaseId,
|
|
1527
1598
|
String detail
|
|
1599
|
+
) {
|
|
1600
|
+
sendDeviceEvent(action, bundleVersion, runtimeVersion, channel, releaseId, detail, null, null);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
private void sendDeviceEvent(
|
|
1604
|
+
String action,
|
|
1605
|
+
String bundleVersion,
|
|
1606
|
+
String runtimeVersion,
|
|
1607
|
+
String channel,
|
|
1608
|
+
String releaseId,
|
|
1609
|
+
String detail,
|
|
1610
|
+
String attemptId,
|
|
1611
|
+
String phase
|
|
1528
1612
|
) {
|
|
1529
1613
|
sendDeviceEvent(
|
|
1530
1614
|
new UpdaterCoordinator.DeviceEventPayload(
|
|
@@ -1533,7 +1617,9 @@ public class UpdaterPlugin extends Plugin {
|
|
|
1533
1617
|
runtimeVersion,
|
|
1534
1618
|
channel,
|
|
1535
1619
|
releaseId,
|
|
1536
|
-
detail
|
|
1620
|
+
detail,
|
|
1621
|
+
attemptId,
|
|
1622
|
+
phase
|
|
1537
1623
|
)
|
|
1538
1624
|
);
|
|
1539
1625
|
}
|
|
@@ -1543,12 +1629,12 @@ public class UpdaterPlugin extends Plugin {
|
|
|
1543
1629
|
return;
|
|
1544
1630
|
}
|
|
1545
1631
|
String normalizedBundleVersion = trimToNull(payload.bundleVersion);
|
|
1546
|
-
if (normalizedBundleVersion == null) {
|
|
1632
|
+
if (normalizedBundleVersion == null && !"check_error".equals(payload.action)) {
|
|
1547
1633
|
android.util.Log.w("OtaKit", "Skipping device event without bundleVersion");
|
|
1548
1634
|
return;
|
|
1549
1635
|
}
|
|
1550
1636
|
String normalizedReleaseId = trimToNull(payload.releaseId);
|
|
1551
|
-
if (normalizedReleaseId == null) {
|
|
1637
|
+
if (normalizedReleaseId == null && !"check_error".equals(payload.action)) {
|
|
1552
1638
|
android.util.Log.w("OtaKit", "Skipping device event without releaseId");
|
|
1553
1639
|
return;
|
|
1554
1640
|
}
|
|
@@ -1567,7 +1653,10 @@ public class UpdaterPlugin extends Plugin {
|
|
|
1567
1653
|
trimToNull(payload.runtimeVersion),
|
|
1568
1654
|
normalizedReleaseId,
|
|
1569
1655
|
nativeBuild,
|
|
1570
|
-
payload.detail
|
|
1656
|
+
payload.detail,
|
|
1657
|
+
payload.attemptId,
|
|
1658
|
+
payload.phase,
|
|
1659
|
+
lifecycleState
|
|
1571
1660
|
);
|
|
1572
1661
|
}
|
|
1573
1662
|
|