@otakit/capacitor-updater 2.1.0 → 2.1.2

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.
@@ -5,7 +5,6 @@ import android.content.pm.PackageManager;
5
5
  import android.os.Build;
6
6
  import android.os.Handler;
7
7
  import android.os.Looper;
8
- import com.getcapacitor.JSArray;
9
8
  import com.getcapacitor.JSObject;
10
9
  import com.getcapacitor.Plugin;
11
10
  import com.getcapacitor.PluginCall;
@@ -19,23 +18,89 @@ import java.net.HttpURLConnection;
19
18
  import java.net.URL;
20
19
  import java.nio.charset.StandardCharsets;
21
20
  import java.security.MessageDigest;
21
+ import java.util.concurrent.CountDownLatch;
22
22
  import java.util.concurrent.ExecutorService;
23
23
  import java.util.concurrent.Executors;
24
- import java.util.concurrent.atomic.AtomicBoolean;
24
+ import java.util.concurrent.atomic.AtomicReference;
25
25
 
26
26
  @CapacitorPlugin(name = "OtaKit")
27
27
  public class UpdaterPlugin extends Plugin {
28
28
 
29
+ private enum Policy {
30
+ OFF("off"),
31
+ SHADOW("shadow"),
32
+ APPLY_STAGED("apply-staged"),
33
+ IMMEDIATE("immediate");
34
+
35
+ final String value;
36
+
37
+ Policy(String value) {
38
+ this.value = value;
39
+ }
40
+ }
41
+
42
+ private static final class CheckResolution {
43
+
44
+ final String kind;
45
+ final ManifestClient.LatestManifest latest;
46
+ final BundleInfo bundle;
47
+
48
+ private CheckResolution(String kind, ManifestClient.LatestManifest latest, BundleInfo bundle) {
49
+ this.kind = kind;
50
+ this.latest = latest;
51
+ this.bundle = bundle;
52
+ }
53
+
54
+ static CheckResolution noUpdate() {
55
+ return new CheckResolution("no_update", null, null);
56
+ }
57
+
58
+ static CheckResolution alreadyStaged(ManifestClient.LatestManifest latest, BundleInfo bundle) {
59
+ return new CheckResolution("already_staged", latest, bundle);
60
+ }
61
+
62
+ static CheckResolution updateAvailable(ManifestClient.LatestManifest latest) {
63
+ return new CheckResolution("update_available", latest, null);
64
+ }
65
+ }
66
+
67
+ private static final class DownloadResolution {
68
+
69
+ final String kind;
70
+ final BundleInfo bundle;
71
+
72
+ private DownloadResolution(String kind, BundleInfo bundle) {
73
+ this.kind = kind;
74
+ this.bundle = bundle;
75
+ }
76
+
77
+ static DownloadResolution noUpdate() {
78
+ return new DownloadResolution("no_update", null);
79
+ }
80
+
81
+ static DownloadResolution staged(BundleInfo bundle) {
82
+ return new DownloadResolution("staged", bundle);
83
+ }
84
+ }
85
+
86
+ @FunctionalInterface
87
+ private interface ThrowingRunnable {
88
+ void run() throws Exception;
89
+ }
90
+
29
91
  private final ExecutorService executor = Executors.newSingleThreadExecutor();
30
92
  private final Handler mainHandler = new Handler(Looper.getMainLooper());
31
93
  private final ZipUtils zipUtils = new ZipUtils();
32
94
 
33
95
  private BundleStore store;
96
+ private UpdaterCoordinator coordinator;
34
97
  private Runnable trialTimeoutRunnable;
35
98
 
36
99
  private int appReadyTimeoutMs = 10_000;
37
100
  private boolean allowInsecureUrls = false;
38
- private String updateMode = UPDATE_MODE_NEXT_LAUNCH;
101
+ private Policy launchPolicy = Policy.APPLY_STAGED;
102
+ private Policy resumePolicy = Policy.SHADOW;
103
+ private Policy runtimePolicy = Policy.IMMEDIATE;
39
104
  private String ingestUrl;
40
105
  private String cdnUrl;
41
106
  private String appId;
@@ -43,17 +108,14 @@ public class UpdaterPlugin extends Plugin {
43
108
  private String runtimeVersion;
44
109
  private java.util.List<ManifestVerifier.KeyEntry> manifestKeys = new java.util.ArrayList<>();
45
110
  private long checkIntervalMs = 600_000;
46
- private final AtomicBoolean checkInProgress = new AtomicBoolean(false);
111
+ private boolean coldStartInProgress = false;
47
112
  private static final String DEFAULT_INGEST_URL = "https://ingest.otakit.app/v1";
48
113
  private static final String DEFAULT_CDN_URL = "https://cdn.otakit.app";
49
114
  private static final String INGEST_PATH_SUFFIX = "/v1";
50
- private static final String UPDATE_MODE_MANUAL = "manual";
51
- private static final String UPDATE_MODE_NEXT_LAUNCH = "next-launch";
52
- private static final String UPDATE_MODE_NEXT_RESUME = "next-resume";
53
- private static final String UPDATE_MODE_IMMEDIATE = "immediate";
54
115
  private static final String KEY_LAST_CHECK_TIMESTAMP = "last_check_timestamp";
55
- private static final String TRIGGER_LAUNCH = "launch";
56
- private static final String TRIGGER_RESUME = "resume";
116
+ private static final String DEFAULT_RUNTIME_KEY = "__default__";
117
+ private static final String BUILTIN_ASSET_PATH = "public";
118
+ private UpdaterCoordinator.StartupPreparation pendingStartupPreparation;
57
119
 
58
120
  @Override
59
121
  public void load() {
@@ -78,17 +140,16 @@ public class UpdaterPlugin extends Plugin {
78
140
  getConfig().getString("ingestUrl"),
79
141
  System.getenv("OTAKIT_INGEST_URL")
80
142
  );
81
- this.cdnUrl = resolveCdnUrl(
82
- getConfig().getString("cdnUrl"),
83
- System.getenv("OTAKIT_CDN_URL")
84
- );
143
+ this.cdnUrl = resolveCdnUrl(getConfig().getString("cdnUrl"), System.getenv("OTAKIT_CDN_URL"));
85
144
  this.appId = getConfig().getString("appId");
86
145
  this.channel = trimToNull(getConfig().getString("channel"));
87
146
  this.runtimeVersion = trimToNull(getConfig().getString("runtimeVersion"));
88
147
  this.store = new BundleStore(getContext(), builtinVersion, nativeBuild, this.runtimeVersion);
148
+ this.coordinator = new UpdaterCoordinator(this.store);
89
149
  this.allowInsecureUrls = getConfig().getBoolean("allowInsecureUrls", false);
90
- String configuredUpdateMode = getConfig().getString("updateMode", UPDATE_MODE_NEXT_LAUNCH);
91
- this.updateMode = resolveUpdateMode(configuredUpdateMode);
150
+ this.launchPolicy = resolvePolicy(getConfig().getString("launchPolicy"), Policy.APPLY_STAGED);
151
+ this.resumePolicy = resolvePolicy(getConfig().getString("resumePolicy"), Policy.SHADOW);
152
+ this.runtimePolicy = resolvePolicy(getConfig().getString("runtimePolicy"), Policy.IMMEDIATE);
92
153
 
93
154
  try {
94
155
  org.json.JSONArray rawKeys = getConfig().getConfigJSON().optJSONArray("manifestKeys");
@@ -127,302 +188,337 @@ public class UpdaterPlugin extends Plugin {
127
188
  }
128
189
 
129
190
  this.appReadyTimeoutMs = Math.max(1000, getConfig().getInt("appReadyTimeout", 10_000));
130
- this.checkIntervalMs = Math.max(600_000, getConfig().getInt("checkInterval", 600_000));
191
+ this.checkIntervalMs = getConfig().getInt("checkInterval", 600_000);
131
192
 
132
193
  pruneIncompatibleBundles();
133
194
 
134
- BundleInfo current = store.getCurrentBundle();
135
- if (current.status == BundleStatus.TRIAL) {
136
- rollbackCurrentBundle("app_restarted_before_notify");
137
- current = store.getCurrentBundle();
138
- }
195
+ UpdaterCoordinator.StartupPreparation startup = coordinator.normalizeStartupState(
196
+ this::isBundleUsable
197
+ );
198
+ coordinator.cleanupBundles(startup.cleanupBundleIds);
199
+ pendingStartupPreparation = startup;
200
+ }
139
201
 
140
- if (shouldActivateStagedOnLaunch()) {
141
- current = activateStagedBundleForLaunch();
142
- }
202
+ @Override
203
+ protected void handleOnStart() {
204
+ super.handleOnStart();
205
+ consumePendingStartupPreparation();
206
+ }
143
207
 
144
- if (!current.isBuiltin() && current.path != null) {
145
- applyServerBasePath(current.path);
146
- } else {
147
- applyServerBasePath(null);
208
+ @Override
209
+ protected void handleOnResume() {
210
+ super.handleOnResume();
211
+ if (coldStartInProgress) {
212
+ coldStartInProgress = false;
213
+ return;
148
214
  }
215
+ handleResume();
216
+ }
149
217
 
150
- if (current.status == BundleStatus.PENDING) {
151
- store.markStatus(current.id, BundleStatus.TRIAL);
152
- scheduleTrialTimeout(current.id);
153
- }
218
+ private boolean shouldSkipCheckInterval() {
219
+ if (checkIntervalMs <= 0) return false;
220
+ long lastCheck = store.getPrefs().getLong(KEY_LAST_CHECK_TIMESTAMP, 0);
221
+ if (lastCheck <= 0) return false;
222
+ long elapsed = System.currentTimeMillis() - lastCheck;
223
+ return elapsed < checkIntervalMs;
224
+ }
154
225
 
155
- if (isAutomaticUpdateMode()) {
156
- runAutomaticUpdate(TRIGGER_LAUNCH);
157
- }
226
+ private void recordCheckTimestamp() {
227
+ store.getPrefs().edit().putLong(KEY_LAST_CHECK_TIMESTAMP, System.currentTimeMillis()).apply();
158
228
  }
159
229
 
160
- @Override
161
- protected void handleOnResume() {
162
- super.handleOnResume();
163
- if (isAutomaticUpdateMode()) {
164
- runAutomaticUpdate(TRIGGER_RESUME);
230
+ private void dispatchColdStart() {
231
+ if (isRuntimeUnresolved()) {
232
+ handleRuntime();
233
+ } else {
234
+ handleLaunch();
165
235
  }
166
236
  }
167
237
 
168
- private void runAutomaticUpdate(String trigger) {
169
- // Resume-only guards
170
- if (TRIGGER_RESUME.equals(trigger)) {
171
- if (UPDATE_MODE_MANUAL.equals(updateMode)) return;
238
+ private void consumePendingStartupPreparation() {
239
+ UpdaterCoordinator.StartupPreparation startup = pendingStartupPreparation;
240
+ if (startup == null) {
241
+ return;
242
+ }
243
+ pendingStartupPreparation = null;
172
244
 
173
- // next-resume and immediate: activate staged bundle on resume without server check
174
- if ((UPDATE_MODE_NEXT_RESUME.equals(updateMode) || UPDATE_MODE_IMMEDIATE.equals(updateMode))) {
175
- String stagedId = store.getStagedBundleId();
176
- if (stagedId != null && store.getBundle(stagedId) != null) {
177
- activateStagedBundleForReload();
178
- reloadWebView();
179
- return;
180
- }
245
+ if (startup.activationPath != null && !startup.activationPath.isEmpty()) {
246
+ try {
247
+ applyServerBasePathSynchronously(startup.activationPath);
248
+ } catch (Exception e) {
249
+ android.util.Log.w("OtaKit", "startup activation failed", e);
181
250
  }
251
+ }
182
252
 
183
- if (!UPDATE_MODE_IMMEDIATE.equals(updateMode) && shouldThrottleCheck()) return;
253
+ if (startup.eventPayload != null) {
254
+ sendDeviceEvent(startup.eventPayload);
184
255
  }
185
256
 
186
- if (
187
- TRIGGER_LAUNCH.equals(trigger) &&
188
- !UPDATE_MODE_IMMEDIATE.equals(updateMode) &&
189
- shouldThrottleCheck()
190
- ) {
191
- return;
257
+ if (startup.trialBundleId != null) {
258
+ scheduleTrialTimeout(startup.trialBundleId);
259
+ } else {
260
+ cancelTrialTimeout();
192
261
  }
193
262
 
194
- // Acquire in-flight guard (submit-time)
195
- if (!checkInProgress.compareAndSet(false, true)) return;
263
+ coldStartInProgress = true;
264
+ dispatchColdStart();
265
+ }
266
+
267
+ private void handleRuntime() {
268
+ switch (runtimePolicy) {
269
+ case OFF:
270
+ resolveCurrentRuntimeKey();
271
+ return;
272
+ case APPLY_STAGED:
273
+ boolean hasStagedBundle =
274
+ coordinator.snapshotState(
275
+ bundle -> isCompatibleRuntime(bundle) && isBundleUsable(bundle)
276
+ ).staged !=
277
+ null;
278
+ if (hasStagedBundle) {
279
+ resolveCurrentRuntimeKey();
280
+ try {
281
+ if (!applyStaged(false)) {
282
+ android.util.Log.w(
283
+ "OtaKit",
284
+ "Failed to apply a valid staged bundle during runtime handling"
285
+ );
286
+ }
287
+ } catch (Exception e) {
288
+ android.util.Log.w("OtaKit", "runtime apply-staged failed", e);
289
+ }
290
+ return;
291
+ }
292
+ executeAutomaticUpdate("runtime apply-staged fallback", () -> {
293
+ downloadLatest(false, null);
294
+ resolveCurrentRuntimeKey();
295
+ });
296
+ return;
297
+ case SHADOW:
298
+ executeAutomaticUpdate("runtime shadow", () -> {
299
+ downloadLatest(false, null);
300
+ resolveCurrentRuntimeKey();
301
+ });
302
+ return;
303
+ case IMMEDIATE:
304
+ executeAutomaticUpdate("runtime immediate", () -> {
305
+ DownloadResolution result = downloadLatest(false, null);
306
+ if ("no_update".equals(result.kind)) {
307
+ resolveCurrentRuntimeKey();
308
+ return;
309
+ }
310
+ resolveCurrentRuntimeKey();
311
+ requireApplyStaged(true);
312
+ });
313
+ return;
314
+ }
315
+ }
196
316
 
197
- if (UPDATE_MODE_IMMEDIATE.equals(updateMode)) {
198
- // Immediate: check+download, activate if found
199
- executor.execute(() -> {
317
+ private void handleLaunch() {
318
+ switch (launchPolicy) {
319
+ case OFF:
320
+ return;
321
+ case APPLY_STAGED:
200
322
  try {
201
- BundleInfo result = performCheckAndDownload(null, true);
202
- if (result != null) {
203
- activateStagedBundleForReload();
204
- reloadWebView();
323
+ if (applyStaged(false)) {
324
+ return;
205
325
  }
206
326
  } catch (Exception e) {
207
- android.util.Log.w("OtaKit", "immediate update failed (" + trigger + ")", e);
208
- } finally {
209
- checkInProgress.set(false);
327
+ android.util.Log.w("OtaKit", "launch apply-staged failed", e);
328
+ return;
210
329
  }
211
- });
212
- return;
330
+ executeAutomaticUpdate("launch apply-staged fallback", () -> downloadLatest(false, null));
331
+ return;
332
+ case SHADOW:
333
+ executeAutomaticUpdate("launch shadow", () -> downloadLatest(false, null));
334
+ return;
335
+ case IMMEDIATE:
336
+ executeAutomaticUpdate("launch immediate", () -> {
337
+ DownloadResolution result = downloadLatest(false, null);
338
+ if ("staged".equals(result.kind)) {
339
+ requireApplyStaged(true);
340
+ }
341
+ });
342
+ return;
343
+ }
344
+ }
345
+
346
+ private void handleResume() {
347
+ switch (resumePolicy) {
348
+ case OFF:
349
+ return;
350
+ case APPLY_STAGED:
351
+ executeAutomaticUpdate("resume apply-staged", () -> {
352
+ if (applyStaged(true)) {
353
+ return;
354
+ }
355
+ downloadLatest(true, null);
356
+ });
357
+ return;
358
+ case SHADOW:
359
+ executeAutomaticUpdate("resume shadow", () -> downloadLatest(true, null));
360
+ return;
361
+ case IMMEDIATE:
362
+ executeAutomaticUpdate("resume immediate", () -> {
363
+ DownloadResolution result = downloadLatest(false, null);
364
+ if ("staged".equals(result.kind)) {
365
+ requireApplyStaged(true);
366
+ }
367
+ });
368
+ return;
213
369
  }
370
+ }
214
371
 
215
- // next-launch / next-resume: background check+download (fire-and-forget)
372
+ private void executeAutomaticUpdate(String label, ThrowingRunnable operation) {
373
+ if (!coordinator.tryBeginOperation()) {
374
+ android.util.Log.d("OtaKit", "Skipping " + label + ": update already in progress");
375
+ return;
376
+ }
216
377
  executor.execute(() -> {
217
378
  try {
218
- performCheckAndDownload(null, true);
219
- recordCheckTimestamp();
220
- } catch (Exception ignored) {
221
- // Check failed — timestamp not recorded, will retry on next trigger
379
+ operation.run();
380
+ } catch (Exception e) {
381
+ android.util.Log.w("OtaKit", label + " failed", e);
222
382
  } finally {
223
- checkInProgress.set(false);
383
+ coordinator.endOperation();
224
384
  }
225
385
  });
226
386
  }
227
387
 
228
- private boolean shouldThrottleCheck() {
229
- long lastCheck = store.getPrefs().getLong(KEY_LAST_CHECK_TIMESTAMP, 0);
230
- if (lastCheck <= 0) return false;
231
- long elapsed = System.currentTimeMillis() - lastCheck;
232
- return elapsed < checkIntervalMs;
233
- }
234
-
235
- private void recordCheckTimestamp() {
236
- store.getPrefs().edit()
237
- .putLong(KEY_LAST_CHECK_TIMESTAMP, System.currentTimeMillis())
238
- .apply();
388
+ private String currentRuntimeKey() {
389
+ return runtimeVersion != null ? runtimeVersion : DEFAULT_RUNTIME_KEY;
239
390
  }
240
391
 
241
- private boolean shouldActivateStagedOnLaunch() {
242
- return !UPDATE_MODE_MANUAL.equals(updateMode);
392
+ private boolean isRuntimeUnresolved() {
393
+ return coordinator.isRuntimeUnresolved(currentRuntimeKey());
243
394
  }
244
395
 
245
- private boolean isAutomaticUpdateMode() {
246
- return !UPDATE_MODE_MANUAL.equals(updateMode);
396
+ private void resolveCurrentRuntimeKey() {
397
+ coordinator.resolveRuntimeKey(currentRuntimeKey());
247
398
  }
248
399
 
249
- private String resolveUpdateMode(String configuredUpdateMode) {
250
- if (configuredUpdateMode == null) {
251
- return UPDATE_MODE_NEXT_LAUNCH;
400
+ private Policy resolvePolicy(String configured, Policy defaultPolicy) {
401
+ String raw = configured == null ? "" : configured.trim().toLowerCase();
402
+ if (raw.isEmpty()) {
403
+ return defaultPolicy;
252
404
  }
253
-
254
- String raw = configuredUpdateMode.trim().toLowerCase();
255
- if (raw.isEmpty() || UPDATE_MODE_NEXT_LAUNCH.equals(raw)) {
256
- return UPDATE_MODE_NEXT_LAUNCH;
405
+ if (Policy.OFF.value.equals(raw)) {
406
+ return Policy.OFF;
257
407
  }
258
- if (UPDATE_MODE_MANUAL.equals(raw)) {
259
- return UPDATE_MODE_MANUAL;
408
+ if (Policy.SHADOW.value.equals(raw)) {
409
+ return Policy.SHADOW;
260
410
  }
261
- if (UPDATE_MODE_NEXT_RESUME.equals(raw)) {
262
- return UPDATE_MODE_NEXT_RESUME;
411
+ if (Policy.APPLY_STAGED.value.equals(raw)) {
412
+ return Policy.APPLY_STAGED;
263
413
  }
264
- if (UPDATE_MODE_IMMEDIATE.equals(raw)) {
265
- return UPDATE_MODE_IMMEDIATE;
414
+ if (Policy.IMMEDIATE.value.equals(raw)) {
415
+ return Policy.IMMEDIATE;
266
416
  }
267
-
268
417
  android.util.Log.w(
269
418
  "OtaKit",
270
- "Unknown updateMode '" + raw + "', defaulting to 'next-launch'"
419
+ "Unknown policy '" + raw + "', defaulting to '" + defaultPolicy.value + "'"
271
420
  );
272
- return UPDATE_MODE_NEXT_LAUNCH;
421
+ return defaultPolicy;
273
422
  }
274
423
 
275
424
  @PluginMethod
276
425
  public void getState(PluginCall call) {
426
+ UpdaterCoordinator.StateSnapshot snapshot = coordinator.snapshotState(this::isBundleUsable);
277
427
  JSObject result = new JSObject();
278
- result.put("current", store.getCurrentBundle().toJSObject());
279
- result.put("fallback", store.getFallbackBundle().toJSObject());
280
- result.put("builtinVersion", store.getBuiltinVersion());
281
-
282
- String stagedId = store.getStagedBundleId();
283
- if (stagedId != null) {
284
- BundleInfo staged = store.getBundle(stagedId);
285
- if (staged == null) {
286
- store.setStagedBundleId(null);
287
- }
288
- result.put("staged", staged != null ? staged.toJSObject() : null);
289
- } else {
290
- result.put("staged", null);
291
- }
292
-
428
+ result.put("current", snapshot.current.toJSObject());
429
+ result.put("fallback", snapshot.fallback.toJSObject());
430
+ result.put("builtinVersion", snapshot.builtinVersion);
431
+ result.put("staged", snapshot.staged != null ? snapshot.staged.toJSObject() : null);
293
432
  call.resolve(result);
294
433
  }
295
434
 
296
435
  @PluginMethod
297
436
  public void check(PluginCall call) {
298
- if (!checkInProgress.compareAndSet(false, true)) {
299
- String stagedId = store.getStagedBundleId();
300
- if (stagedId != null) {
301
- BundleInfo staged = store.getBundle(stagedId);
302
- if (staged != null) {
303
- JSObject result = new JSObject();
304
- result.put("version", staged.version);
305
- result.put("url", "");
306
- result.put("sha256", staged.sha256 != null ? staged.sha256 : "");
307
- result.put("size", 0);
308
- result.put("downloaded", true);
309
- if (staged.runtimeVersion != null) {
310
- result.put("runtimeVersion", staged.runtimeVersion);
311
- }
312
- if (staged.releaseId != null) {
313
- result.put("releaseId", staged.releaseId);
314
- }
315
- call.resolve(result);
316
- return;
317
- }
318
- }
319
- call.resolve((JSObject) null);
437
+ if (!coordinator.tryBeginOperation()) {
438
+ call.reject("Another update operation is already in progress");
320
439
  return;
321
440
  }
322
- String targetChannel = resolveTargetChannel(null);
323
441
  executor.execute(() -> {
324
442
  try {
325
- ManifestClient.LatestManifest latest = fetchLatest(targetChannel);
326
- if (latest == null) {
327
- call.resolve((JSObject) null);
328
- } else if (isCurrentBundleLatest(latest, targetChannel)) {
329
- call.resolve((JSObject) null);
330
- } else {
331
- BundleInfo staged = findMatchingStagedBundle(latest, targetChannel);
332
- call.resolve(manifestToJSObject(latest, staged != null));
333
- }
443
+ call.resolve(checkResolutionToJSObject(checkLatest(false, null)));
334
444
  } catch (Exception e) {
335
445
  call.reject("check failed: " + e.getMessage());
336
446
  } finally {
337
- checkInProgress.set(false);
447
+ coordinator.endOperation();
338
448
  }
339
449
  });
340
450
  }
341
451
 
342
452
  @PluginMethod
343
453
  public void download(PluginCall call) {
344
- if (!checkInProgress.compareAndSet(false, true)) {
345
- String stagedId = store.getStagedBundleId();
346
- if (stagedId != null) {
347
- BundleInfo staged = store.getBundle(stagedId);
348
- if (staged != null) {
349
- call.resolve(staged.toJSObject());
350
- return;
351
- }
352
- }
353
- call.resolve((JSObject) null);
454
+ if (!coordinator.tryBeginOperation()) {
455
+ call.reject("Another update operation is already in progress");
354
456
  return;
355
457
  }
356
458
  executor.execute(() -> {
357
459
  try {
358
- BundleInfo bundle = performCheckAndDownload(null, true);
359
- if (bundle == null) {
360
- call.resolve((JSObject) null);
361
- } else {
362
- call.resolve(bundle.toJSObject());
363
- }
460
+ call.resolve(downloadResolutionToJSObject(downloadLatest(false, null)));
364
461
  } catch (Exception e) {
365
462
  call.reject("download failed: " + e.getMessage());
366
463
  } finally {
367
- checkInProgress.set(false);
464
+ coordinator.endOperation();
368
465
  }
369
466
  });
370
467
  }
371
468
 
372
469
  @PluginMethod
373
470
  public void apply(PluginCall call) {
374
- String stagedId = store.getStagedBundleId();
375
- if (stagedId == null) {
376
- call.reject("No staged update to apply");
471
+ if (!coordinator.tryBeginOperation()) {
472
+ call.reject("Another update operation is already in progress");
377
473
  return;
378
474
  }
379
- if (!store.bundleExists(stagedId)) {
380
- store.setStagedBundleId(null);
381
- call.reject("Staged bundle not found");
475
+ executor.execute(() -> {
476
+ try {
477
+ requireApplyStaged(true);
478
+ } catch (Exception e) {
479
+ call.reject("apply failed: " + e.getMessage());
480
+ } finally {
481
+ coordinator.endOperation();
482
+ }
483
+ });
484
+ }
485
+
486
+ @PluginMethod
487
+ public void update(PluginCall call) {
488
+ if (!coordinator.tryBeginOperation()) {
489
+ call.reject("Another update operation is already in progress");
382
490
  return;
383
491
  }
384
-
385
- activateStagedBundleForReload();
386
- call.resolve();
387
- reloadWebView();
492
+ executor.execute(() -> {
493
+ try {
494
+ DownloadResolution result = downloadLatest(false, null);
495
+ if ("staged".equals(result.kind)) {
496
+ requireApplyStaged(true);
497
+ return;
498
+ }
499
+ call.resolve();
500
+ } catch (Exception e) {
501
+ call.reject("update failed: " + e.getMessage());
502
+ } finally {
503
+ coordinator.endOperation();
504
+ }
505
+ });
388
506
  }
389
507
 
390
508
  @PluginMethod
391
509
  public void notifyAppReady(PluginCall call) {
392
- BundleInfo current = store.getCurrentBundle();
393
- if (
394
- !current.isBuiltin() &&
395
- (current.status == BundleStatus.TRIAL || current.status == BundleStatus.PENDING)
396
- ) {
397
- BundleInfo oldFallback = store.getFallbackBundle();
398
-
399
- store.markStatus(current.id, BundleStatus.SUCCESS);
400
- store.setFallbackBundleId(current.id);
401
- BundleInfo updated = store.getBundle(current.id);
402
- notifyListeners("appReady", updated != null ? updated.toJSObject() : current.toJSObject());
403
-
404
- sendDeviceEvent(
405
- "applied",
406
- current.version,
407
- current.runtimeVersion,
408
- current.channel,
409
- current.releaseId,
410
- null
411
- );
412
-
413
- if (!oldFallback.isBuiltin() && !oldFallback.id.equals(current.id)) {
414
- try {
415
- store.deleteBundle(oldFallback.id);
416
- } catch (Exception ignored) {}
417
- }
510
+ cancelTrialTimeout();
511
+ UpdaterCoordinator.NotifyReadyPreparation preparation = coordinator.prepareNotifyAppReady();
512
+ coordinator.cleanupBundles(preparation.cleanupBundleIds);
513
+ if (preparation.eventPayload != null) {
514
+ sendDeviceEvent(preparation.eventPayload);
418
515
  }
419
-
420
516
  call.resolve();
421
517
  }
422
518
 
423
519
  @PluginMethod
424
520
  public void getLastFailure(PluginCall call) {
425
- BundleInfo failed = store.getFailedBundle();
521
+ BundleInfo failed = coordinator.lastFailure();
426
522
  if (failed == null) {
427
523
  call.resolve((JSObject) null);
428
524
  return;
@@ -445,65 +541,109 @@ public class UpdaterPlugin extends Plugin {
445
541
  );
446
542
  }
447
543
 
448
- private BundleInfo performCheckAndDownload(String channel, boolean emitEvents) throws Exception {
544
+ private CheckResolution checkLatest(boolean respectInterval, String channel) throws Exception {
449
545
  String targetChannel = resolveTargetChannel(channel);
546
+ if (respectInterval && shouldSkipCheckInterval()) {
547
+ android.util.Log.d("OtaKit", "Skipping resume check: checkInterval has not elapsed");
548
+ return CheckResolution.noUpdate();
549
+ }
550
+
450
551
  ManifestClient.LatestManifest latest = fetchLatest(targetChannel);
451
552
  if (latest == null) {
452
- if (emitEvents) {
453
- notifyListeners("noUpdateAvailable", new JSObject());
553
+ if (respectInterval) {
554
+ recordCheckTimestamp();
454
555
  }
455
- return null;
556
+ return CheckResolution.noUpdate();
557
+ }
558
+
559
+ CheckResolution resolution = classifyLatestManifest(latest, targetChannel);
560
+
561
+ if (respectInterval) {
562
+ recordCheckTimestamp();
456
563
  }
564
+ return resolution;
565
+ }
566
+
567
+ private DownloadResolution downloadLatest(boolean respectInterval, String channel)
568
+ throws Exception {
569
+ String targetChannel = resolveTargetChannel(channel);
570
+ CheckResolution result = checkLatest(respectInterval, channel);
571
+ switch (result.kind) {
572
+ case "no_update":
573
+ return DownloadResolution.noUpdate();
574
+ case "already_staged":
575
+ return DownloadResolution.staged(result.bundle);
576
+ case "update_available":
577
+ try {
578
+ return DownloadResolution.staged(downloadLatestManifest(result.latest, targetChannel));
579
+ } catch (Exception e) {
580
+ if (!isExpiredURLError(e)) {
581
+ throw e;
582
+ }
583
+
584
+ ManifestClient.LatestManifest refreshed = fetchLatest(targetChannel);
585
+ if (refreshed == null) {
586
+ return DownloadResolution.noUpdate();
587
+ }
588
+
589
+ CheckResolution refreshedResolution = classifyLatestManifest(refreshed, targetChannel);
590
+ switch (refreshedResolution.kind) {
591
+ case "no_update":
592
+ return DownloadResolution.noUpdate();
593
+ case "already_staged":
594
+ return DownloadResolution.staged(refreshedResolution.bundle);
595
+ case "update_available":
596
+ return DownloadResolution.staged(
597
+ downloadLatestManifest(refreshedResolution.latest, targetChannel)
598
+ );
599
+ default:
600
+ throw new IllegalStateException(
601
+ "Unknown refreshed check result: " + refreshedResolution.kind
602
+ );
603
+ }
604
+ }
605
+ default:
606
+ throw new IllegalStateException("Unknown check result: " + result.kind);
607
+ }
608
+ }
457
609
 
610
+ private CheckResolution classifyLatestManifest(
611
+ ManifestClient.LatestManifest latest,
612
+ String targetChannel
613
+ ) throws Exception {
458
614
  if (!isCompatibleRuntime(latest.runtimeVersion)) {
459
615
  throw new IllegalStateException(
460
616
  "Manifest runtimeVersion does not match the installed app runtime"
461
617
  );
462
618
  }
463
619
 
464
- if (isCurrentBundleLatest(latest, targetChannel)) {
465
- if (emitEvents) {
466
- notifyListeners("noUpdateAvailable", new JSObject());
467
- }
468
- return null;
620
+ UpdaterCoordinator.LatestManifestClassification classification =
621
+ coordinator.classifyLatestManifest(latest, targetChannel, this::isBundleUsable);
622
+ if ("no_update".equals(classification.kind)) {
623
+ return CheckResolution.noUpdate();
469
624
  }
470
-
471
- BundleInfo staged = findMatchingStagedBundle(latest, targetChannel);
472
- if (emitEvents) {
473
- notifyListeners("updateAvailable", manifestToJSObject(latest, staged != null));
625
+ if ("already_staged".equals(classification.kind)) {
626
+ return CheckResolution.alreadyStaged(latest, classification.bundle);
474
627
  }
628
+ return CheckResolution.updateAvailable(latest);
629
+ }
475
630
 
476
- if (staged != null) {
477
- return staged;
631
+ private JSObject checkResolutionToJSObject(CheckResolution result) {
632
+ JSObject object = new JSObject();
633
+ object.put("kind", result.kind);
634
+ if (result.latest != null) {
635
+ object.put("latest", manifestToJSObject(result.latest));
478
636
  }
637
+ return object;
638
+ }
479
639
 
480
- try {
481
- return downloadAndStage(
482
- new URL(latest.url),
483
- latest.version,
484
- latest.sha256,
485
- latest.size,
486
- latest.runtimeVersion,
487
- targetChannel,
488
- latest.releaseId
489
- );
490
- } catch (Exception e) {
491
- if (isExpiredURLError(e)) {
492
- // Download URL may have expired — re-fetch manifest once and retry
493
- ManifestClient.LatestManifest refreshed = fetchLatest(targetChannel);
494
- if (refreshed == null) throw e;
495
- return downloadAndStage(
496
- new URL(refreshed.url),
497
- refreshed.version,
498
- refreshed.sha256,
499
- refreshed.size,
500
- refreshed.runtimeVersion,
501
- targetChannel,
502
- refreshed.releaseId
503
- );
504
- }
505
- throw e;
640
+ private JSObject downloadResolutionToJSObject(DownloadResolution result) {
641
+ JSObject object = new JSObject();
642
+ object.put("kind", result.kind);
643
+ if (result.bundle != null) {
644
+ object.put("bundle", result.bundle.toJSObject());
506
645
  }
646
+ return object;
507
647
  }
508
648
 
509
649
  private boolean isExpiredURLError(Exception e) {
@@ -518,11 +658,6 @@ public class UpdaterPlugin extends Plugin {
518
658
  );
519
659
  }
520
660
 
521
- private BundleInfo downloadAndStage(URL url, String version, String expectedSha256)
522
- throws Exception {
523
- return downloadAndStage(url, version, expectedSha256, 0, null, null, null);
524
- }
525
-
526
661
  private BundleInfo downloadAndStage(
527
662
  URL url,
528
663
  String version,
@@ -549,10 +684,6 @@ public class UpdaterPlugin extends Plugin {
549
684
  }
550
685
  }
551
686
 
552
- JSObject start = new JSObject();
553
- start.put("version", version);
554
- notifyListeners("downloadStarted", start);
555
-
556
687
  File downloadedZip = null;
557
688
  File extractedDirectory = null;
558
689
 
@@ -573,8 +704,8 @@ public class UpdaterPlugin extends Plugin {
573
704
  zipUtils.extractSecurely(downloadedZip, extractedDirectory);
574
705
  File bundleRoot = resolveBundleRoot(extractedDirectory);
575
706
 
576
- String bundleId = buildBundleId(version);
577
- File destination = store.bundleDirectory(bundleId);
707
+ String bundleId = buildBundleId(version, releaseId, expectedSha256);
708
+ File destination = coordinator.bundleDirectory(bundleId);
578
709
  if (destination.exists()) {
579
710
  deleteRecursively(destination);
580
711
  }
@@ -591,20 +722,20 @@ public class UpdaterPlugin extends Plugin {
591
722
  channel,
592
723
  releaseId
593
724
  );
594
- String previousStagedId = store.getStagedBundleId();
595
- store.saveBundle(info);
596
- store.setStagedBundleId(bundleId);
597
- cleanupSupersededStagedBundle(previousStagedId, bundleId);
725
+ java.util.List<String> cleanupBundleIds = coordinator.stageDownloadedBundle(info);
726
+ coordinator.cleanupBundles(cleanupBundleIds);
598
727
 
599
- notifyListeners("downloadComplete", info.toJSObject());
600
728
  sendDeviceEvent("downloaded", version, runtimeVersion, channel, releaseId, null);
601
729
  return info;
602
730
  } catch (Exception e) {
603
- JSObject failed = new JSObject();
604
- failed.put("version", version);
605
- failed.put("error", e.getMessage());
606
- notifyListeners("downloadFailed", failed);
607
- sendDeviceEvent("download_error", version, runtimeVersion, channel, releaseId, e.getMessage());
731
+ sendDeviceEvent(
732
+ "download_error",
733
+ version,
734
+ runtimeVersion,
735
+ channel,
736
+ releaseId,
737
+ e.getMessage()
738
+ );
608
739
  throw e;
609
740
  } finally {
610
741
  if (downloadedZip != null && downloadedZip.exists()) {
@@ -668,71 +799,39 @@ public class UpdaterPlugin extends Plugin {
668
799
  throw new IllegalStateException("Bundle archive does not contain index.html");
669
800
  }
670
801
 
671
- private BundleInfo activateStagedBundleForLaunch() {
672
- String stagedId = store.getStagedBundleId();
673
- if (stagedId == null) {
674
- return store.getCurrentBundle();
675
- }
676
-
677
- BundleInfo staged = store.getBundle(stagedId);
678
- if (staged == null) {
679
- store.setStagedBundleId(null);
680
- return store.getCurrentBundle();
681
- }
682
- if (!isCompatibleRuntime(staged)) {
683
- try {
684
- store.deleteBundle(staged.id);
685
- } catch (Exception ignored) {}
686
- return store.getCurrentBundle();
687
- }
688
-
689
- store.setCurrentBundleId(staged.id);
690
- store.setStagedBundleId(null);
691
- return staged;
692
- }
693
-
694
- private void activateStagedBundleForReload() {
695
- String stagedId = store.getStagedBundleId();
696
- if (stagedId == null) {
697
- return;
802
+ private boolean applyStaged(boolean reloadAfterApply) throws Exception {
803
+ UpdaterCoordinator.ApplyPreparation preparation = coordinator.prepareApplyStaged(
804
+ this::isCompatibleRuntime,
805
+ this::isBundleUsable
806
+ );
807
+ coordinator.cleanupBundles(preparation.cleanupBundleIds);
808
+ if (!preparation.didApply()) {
809
+ return false;
698
810
  }
699
811
 
700
- BundleInfo staged = store.getBundle(stagedId);
701
- if (staged == null) {
702
- store.setStagedBundleId(null);
703
- return;
704
- }
705
- if (!isCompatibleRuntime(staged)) {
706
- try {
707
- store.deleteBundle(staged.id);
708
- } catch (Exception ignored) {}
709
- return;
812
+ applyServerBasePathSynchronously(preparation.activationPath);
813
+ if (reloadAfterApply) {
814
+ reloadWebViewSynchronously();
710
815
  }
711
816
 
712
- store.setCurrentBundleId(staged.id);
713
- store.setStagedBundleId(null);
714
-
715
- if (staged.status == BundleStatus.PENDING) {
716
- store.markStatus(staged.id, BundleStatus.TRIAL);
717
- staged = store.getBundle(staged.id);
718
- }
719
- if (staged != null && staged.status == BundleStatus.TRIAL) {
720
- scheduleTrialTimeout(staged.id);
817
+ cancelTrialTimeout();
818
+ if (preparation.trialBundleId != null) {
819
+ scheduleTrialTimeout(preparation.trialBundleId);
721
820
  }
821
+ return true;
822
+ }
722
823
 
723
- if (staged != null && staged.path != null) {
724
- applyServerBasePath(staged.path);
725
- } else {
726
- applyServerBasePath(null);
824
+ private void requireApplyStaged(boolean reloadAfterApply) throws Exception {
825
+ if (!applyStaged(reloadAfterApply)) {
826
+ throw new IllegalStateException("Expected a staged bundle to be ready for apply");
727
827
  }
728
828
  }
729
829
 
730
830
  private void scheduleTrialTimeout(String bundleId) {
731
831
  cancelTrialTimeout();
732
832
  trialTimeoutRunnable = () -> {
733
- BundleInfo current = store.getCurrentBundle();
734
- if (current.id.equals(bundleId) && current.status == BundleStatus.TRIAL) {
735
- rollbackCurrentBundle("notify_timeout");
833
+ if (coordinator.isCurrentTrialBundle(bundleId)) {
834
+ rollbackCurrentBundle("notify_timeout", true);
736
835
  }
737
836
  };
738
837
  mainHandler.postDelayed(trialTimeoutRunnable, appReadyTimeoutMs);
@@ -745,103 +844,93 @@ public class UpdaterPlugin extends Plugin {
745
844
  }
746
845
  }
747
846
 
748
- private void rollbackCurrentBundle(String reason) {
847
+ private void rollbackCurrentBundle(String reason, boolean shouldReload) {
749
848
  cancelTrialTimeout();
750
-
751
- BundleInfo current = store.getCurrentBundle();
752
- if (current.isBuiltin()) {
849
+ UpdaterCoordinator.RollbackPreparation preparation = coordinator.prepareRollback(
850
+ reason,
851
+ this::isBundleUsable
852
+ );
853
+ if (!preparation.didRollback) {
753
854
  return;
754
855
  }
755
-
756
- BundleInfo failed = current.withStatus(BundleStatus.ERROR);
757
- store.markStatus(current.id, BundleStatus.ERROR);
758
- store.setFailedBundle(failed);
759
- store.setStagedBundleId(null);
760
-
761
- sendDeviceEvent(
762
- "rollback",
763
- current.version,
764
- current.runtimeVersion,
765
- current.channel,
766
- current.releaseId,
767
- reason
768
- );
769
-
770
- BundleInfo fallback = store.getFallbackBundle();
771
- JSObject payload = new JSObject();
772
- payload.put("from", failed.toJSObject());
773
-
774
- if (!fallback.isBuiltin() && fallback.path != null) {
775
- store.setCurrentBundleId(fallback.id);
776
- applyServerBasePath(fallback.path);
777
- payload.put("to", fallback.toJSObject());
778
- } else {
779
- store.setCurrentBundleId(null);
780
- applyServerBasePath(null);
781
- payload.put("to", store.builtinBundle().toJSObject());
856
+ coordinator.cleanupBundles(preparation.cleanupBundleIds);
857
+ if (preparation.eventPayload != null) {
858
+ sendDeviceEvent(preparation.eventPayload);
782
859
  }
783
- payload.put("reason", reason);
784
-
785
- notifyListeners("rollback", payload);
786
860
 
787
861
  try {
788
- store.deleteBundle(current.id);
789
- } catch (Exception ignored) {}
790
-
791
- reloadWebView();
862
+ applyServerBasePathSynchronously(preparation.activationPath);
863
+ if (shouldReload) {
864
+ reloadWebViewSynchronously();
865
+ }
866
+ } catch (Exception e) {
867
+ android.util.Log.w("OtaKit", "rollback activation failed", e);
868
+ }
792
869
  }
793
870
 
794
- private void cleanupSupersededStagedBundle(String previousStagedId, String replacementId) {
795
- if (
796
- previousStagedId == null ||
797
- previousStagedId.equals(replacementId) ||
798
- "builtin".equals(previousStagedId)
799
- ) {
800
- return;
801
- }
871
+ private void applyServerBasePathSynchronously(String path) throws Exception {
872
+ runOnMainSynchronously(() -> {
873
+ if (bridge == null) {
874
+ throw new IllegalStateException("Bridge not available for activation");
875
+ }
876
+ if (path == null || path.isEmpty()) {
877
+ bridge.setServerAssetPath(BUILTIN_ASSET_PATH);
878
+ } else {
879
+ bridge.setServerBasePath(path);
880
+ }
881
+ });
882
+ }
802
883
 
803
- BundleInfo current = store.getCurrentBundle();
804
- if (previousStagedId.equals(current.id)) {
805
- return;
806
- }
884
+ private void reloadWebViewSynchronously() throws Exception {
885
+ runOnMainSynchronously(() -> {
886
+ if (bridge == null || bridge.getWebView() == null) {
887
+ throw new IllegalStateException("WebView not available for reload");
888
+ }
889
+ bridge.getWebView().reload();
890
+ });
891
+ }
807
892
 
808
- BundleInfo fallback = store.getFallbackBundle();
809
- if (previousStagedId.equals(fallback.id)) {
893
+ private void runOnMainSynchronously(ThrowingRunnable work) throws Exception {
894
+ if (Looper.myLooper() == Looper.getMainLooper()) {
895
+ work.run();
810
896
  return;
811
897
  }
812
898
 
813
- try {
814
- store.deleteBundle(previousStagedId);
815
- } catch (Exception ignored) {}
816
- }
817
-
818
- private void applyServerBasePath(String path) {
899
+ CountDownLatch latch = new CountDownLatch(1);
900
+ AtomicReference<Throwable> failure = new AtomicReference<>();
819
901
  mainHandler.post(() -> {
820
902
  try {
821
- if (path == null || path.isEmpty()) {
822
- bridge.setServerBasePath("");
823
- } else {
824
- bridge.setServerBasePath(path);
825
- }
826
- } catch (Throwable ignored) {}
827
- });
828
- }
829
-
830
- private void reloadWebView() {
831
- mainHandler.post(() -> {
832
- if (bridge != null && bridge.getWebView() != null) {
833
- bridge.getWebView().reload();
903
+ work.run();
904
+ } catch (Throwable error) {
905
+ failure.set(error);
906
+ } finally {
907
+ latch.countDown();
834
908
  }
835
909
  });
910
+
911
+ try {
912
+ latch.await();
913
+ } catch (InterruptedException e) {
914
+ Thread.currentThread().interrupt();
915
+ throw new IllegalStateException("Interrupted while waiting for main thread activation", e);
916
+ }
917
+
918
+ Throwable error = failure.get();
919
+ if (error == null) {
920
+ return;
921
+ }
922
+ if (error instanceof Exception) {
923
+ throw (Exception) error;
924
+ }
925
+ throw new RuntimeException(error);
836
926
  }
837
927
 
838
- private JSObject manifestToJSObject(ManifestClient.LatestManifest latest, boolean downloaded) {
928
+ private JSObject manifestToJSObject(ManifestClient.LatestManifest latest) {
839
929
  JSObject object = new JSObject();
840
930
  object.put("version", latest.version);
841
931
  object.put("url", latest.url);
842
932
  object.put("sha256", latest.sha256);
843
933
  object.put("size", latest.size);
844
- object.put("downloaded", downloaded);
845
934
  if (latest.runtimeVersion != null) {
846
935
  object.put("runtimeVersion", latest.runtimeVersion);
847
936
  }
@@ -849,78 +938,19 @@ public class UpdaterPlugin extends Plugin {
849
938
  return object;
850
939
  }
851
940
 
852
- private boolean isCurrentBundleLatest(
853
- ManifestClient.LatestManifest latest,
854
- String targetChannel
855
- ) {
856
- return doesBundleMatchLatest(store.getCurrentBundle(), latest, targetChannel);
857
- }
858
-
859
- private boolean doesBundleMatchLatest(
860
- BundleInfo bundle,
861
- ManifestClient.LatestManifest latest,
862
- String targetChannel
863
- ) {
864
- if (bundle == null) {
865
- return false;
866
- }
867
-
868
- if (!java.util.Objects.equals(trimToNull(bundle.channel), targetChannel)) {
869
- return false;
870
- }
871
-
872
- if (
873
- !java.util.Objects.equals(
874
- trimToNull(bundle.runtimeVersion),
875
- trimToNull(latest.runtimeVersion)
876
- )
877
- ) {
878
- return false;
879
- }
880
-
881
- if (
882
- latest.releaseId != null &&
883
- bundle.releaseId != null &&
884
- latest.releaseId.equals(bundle.releaseId)
885
- ) {
886
- return true;
887
- }
888
-
889
- if (
890
- latest.sha256 != null &&
891
- bundle.sha256 != null &&
892
- latest.sha256.equals(bundle.sha256)
893
- ) {
894
- return true;
895
- }
896
-
897
- return latest.version != null && latest.version.equals(bundle.version);
898
- }
899
-
900
- private BundleInfo findMatchingStagedBundle(
941
+ private BundleInfo downloadLatestManifest(
901
942
  ManifestClient.LatestManifest latest,
902
943
  String targetChannel
903
- ) {
904
- String stagedId = store.getStagedBundleId();
905
- if (stagedId == null) {
906
- return null;
907
- }
908
-
909
- BundleInfo staged = store.getBundle(stagedId);
910
- if (staged == null) {
911
- store.setStagedBundleId(null);
912
- return null;
913
- }
914
-
915
- if (!java.util.Objects.equals(trimToNull(staged.channel), targetChannel)) {
916
- return null;
917
- }
918
-
919
- if (!java.util.Objects.equals(trimToNull(staged.runtimeVersion), trimToNull(latest.runtimeVersion))) {
920
- return null;
921
- }
922
-
923
- return doesBundleMatchLatest(staged, latest, targetChannel) ? staged : null;
944
+ ) throws Exception {
945
+ return downloadAndStage(
946
+ new URL(latest.url),
947
+ latest.version,
948
+ latest.sha256,
949
+ latest.size,
950
+ latest.runtimeVersion,
951
+ targetChannel,
952
+ latest.releaseId
953
+ );
924
954
  }
925
955
 
926
956
  private void moveDirectory(File source, File destination) throws Exception {
@@ -981,7 +1011,7 @@ public class UpdaterPlugin extends Plugin {
981
1011
  }
982
1012
  }
983
1013
 
984
- private String buildBundleId(String version) throws Exception {
1014
+ private String buildBundleId(String version, String releaseId, String sha256) throws Exception {
985
1015
  String trimmed = version == null ? "" : version.trim();
986
1016
  String normalized = trimmed.replaceAll("[^A-Za-z0-9._-]", "-");
987
1017
  normalized = normalized.replaceAll("-{2,}", "-");
@@ -993,8 +1023,16 @@ public class UpdaterPlugin extends Plugin {
993
1023
  normalized = normalized.substring(0, 64);
994
1024
  }
995
1025
 
1026
+ String identitySource = trimToNull(releaseId);
1027
+ if (identitySource == null) {
1028
+ identitySource = trimToNull(sha256);
1029
+ }
1030
+ if (identitySource == null) {
1031
+ identitySource = trimmed;
1032
+ }
1033
+
996
1034
  MessageDigest digest = MessageDigest.getInstance("SHA-256");
997
- byte[] hash = digest.digest(trimmed.getBytes(StandardCharsets.UTF_8));
1035
+ byte[] hash = digest.digest(identitySource.getBytes(StandardCharsets.UTF_8));
998
1036
  StringBuilder suffix = new StringBuilder();
999
1037
  for (int i = 0; i < 6; i++) {
1000
1038
  suffix.append(String.format("%02x", hash[i]));
@@ -1065,20 +1103,33 @@ public class UpdaterPlugin extends Plugin {
1065
1103
  String releaseId,
1066
1104
  String detail
1067
1105
  ) {
1106
+ sendDeviceEvent(
1107
+ new UpdaterCoordinator.DeviceEventPayload(
1108
+ action,
1109
+ bundleVersion,
1110
+ runtimeVersion,
1111
+ channel,
1112
+ releaseId,
1113
+ detail
1114
+ )
1115
+ );
1116
+ }
1117
+
1118
+ private void sendDeviceEvent(UpdaterCoordinator.DeviceEventPayload payload) {
1068
1119
  if (appId == null) {
1069
1120
  return;
1070
1121
  }
1071
- String normalizedBundleVersion = trimToNull(bundleVersion);
1122
+ String normalizedBundleVersion = trimToNull(payload.bundleVersion);
1072
1123
  if (normalizedBundleVersion == null) {
1073
1124
  android.util.Log.w("OtaKit", "Skipping device event without bundleVersion");
1074
1125
  return;
1075
1126
  }
1076
- String normalizedReleaseId = trimToNull(releaseId);
1127
+ String normalizedReleaseId = trimToNull(payload.releaseId);
1077
1128
  if (normalizedReleaseId == null) {
1078
1129
  android.util.Log.w("OtaKit", "Skipping device event without releaseId");
1079
1130
  return;
1080
1131
  }
1081
- String nativeBuild = trimToNull(store.getNativeBuild());
1132
+ String nativeBuild = trimToNull(coordinator.getNativeBuild());
1082
1133
  if (nativeBuild == null) {
1083
1134
  android.util.Log.w("OtaKit", "Skipping device event without nativeBuild");
1084
1135
  return;
@@ -1087,29 +1138,18 @@ public class UpdaterPlugin extends Plugin {
1087
1138
  ingestUrl,
1088
1139
  appId,
1089
1140
  "android",
1090
- action,
1141
+ payload.action,
1091
1142
  normalizedBundleVersion,
1092
- channel,
1093
- trimToNull(runtimeVersion),
1143
+ payload.channel,
1144
+ trimToNull(payload.runtimeVersion),
1094
1145
  normalizedReleaseId,
1095
1146
  nativeBuild,
1096
- detail
1147
+ payload.detail
1097
1148
  );
1098
1149
  }
1099
1150
 
1100
1151
  private void pruneIncompatibleBundles() {
1101
- for (BundleInfo bundle : store.listDownloadedBundleInfos()) {
1102
- if (!isCompatibleRuntime(bundle)) {
1103
- try {
1104
- store.deleteBundle(bundle.id);
1105
- } catch (Exception ignored) {}
1106
- }
1107
- }
1108
-
1109
- BundleInfo failed = store.getFailedBundle();
1110
- if (failed != null && !isCompatibleRuntime(failed)) {
1111
- store.setFailedBundle(null);
1112
- }
1152
+ coordinator.cleanupBundles(coordinator.pruneIncompatibleBundles(this::isCompatibleRuntime));
1113
1153
  }
1114
1154
 
1115
1155
  private boolean isCompatibleRuntime(String bundleRuntimeVersion) {
@@ -1120,6 +1160,24 @@ public class UpdaterPlugin extends Plugin {
1120
1160
  return isCompatibleRuntime(bundle.runtimeVersion);
1121
1161
  }
1122
1162
 
1163
+ private boolean isBundleUsable(BundleInfo bundle) {
1164
+ return bundle != null && (bundle.isBuiltin() || isBundlePathUsable(bundle.path));
1165
+ }
1166
+
1167
+ private boolean isBundlePathUsable(String path) {
1168
+ String normalizedPath = trimToNull(path);
1169
+ if (normalizedPath == null) {
1170
+ return false;
1171
+ }
1172
+
1173
+ File directory = new File(normalizedPath);
1174
+ if (!directory.exists() || !directory.isDirectory()) {
1175
+ return false;
1176
+ }
1177
+
1178
+ return new File(directory, "index.html").exists();
1179
+ }
1180
+
1123
1181
  private long getFreeDiskSpace() {
1124
1182
  try {
1125
1183
  android.os.StatFs statFs = new android.os.StatFs(