@capgo/capacitor-updater 8.51.25 → 8.52.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.
@@ -13,8 +13,5 @@ Pod::Spec.new do |s|
13
13
  s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
14
14
  s.ios.deployment_target = '15.0'
15
15
  s.dependency 'Capacitor'
16
- s.dependency 'ZIPFoundation', '~> 0.9'
17
- s.dependency 'Alamofire', '5.10.2'
18
- s.dependency 'Version', '0.8.0'
19
16
  s.swift_version = '5.1'
20
17
  end
package/Package.swift CHANGED
@@ -10,28 +10,20 @@ let package = Package(
10
10
  targets: ["CapacitorUpdaterPlugin"])
11
11
  ],
12
12
  dependencies: [
13
- .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0"),
14
- .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.12.0")),
15
- .package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.20"),
16
- .package(url: "https://github.com/mrackwitz/Version.git", exact: "0.8.0")
13
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
17
14
  ],
18
15
  targets: [
19
16
  .target(
20
17
  name: "CapacitorUpdaterPlugin",
21
18
  dependencies: [
22
19
  .product(name: "Capacitor", package: "capacitor-swift-pm"),
23
- .product(name: "Cordova", package: "capacitor-swift-pm"),
24
- .product(name: "ZIPFoundation", package: "ZIPFoundation"),
25
- .product(name: "Alamofire", package: "Alamofire"),
26
- .product(name: "Version", package: "Version")
20
+ .product(name: "Cordova", package: "capacitor-swift-pm")
27
21
  ],
28
22
  path: "ios/Sources/CapacitorUpdaterPlugin"),
29
23
  .testTarget(
30
24
  name: "CapacitorUpdaterPluginTests",
31
25
  dependencies: [
32
- "CapacitorUpdaterPlugin",
33
- .product(name: "Version", package: "Version"),
34
- .product(name: "ZIPFoundation", package: "ZIPFoundation")
26
+ "CapacitorUpdaterPlugin"
35
27
  ],
36
28
  path: "ios/Tests/CapacitorUpdaterPluginTests")
37
29
  ],
package/README.md CHANGED
@@ -86,7 +86,7 @@ First follow the migration guide of Capacitor:
86
86
 
87
87
  The minimum iOS version is now **15.0** to match Capacitor 7/8 requirements.
88
88
 
89
- Starting from v8, the plugin uses [ZIPFoundation](https://github.com/weichsel/ZIPFoundation) instead of SSZipArchive/ZipArchive for ZIP extraction. ZIPFoundation uses Apple's native `libcompression` framework, which removes the previous zlib dependency and its associated security constraints.
89
+ The plugin has no third-party iOS dependencies besides Capacitor: ZIP extraction uses a built-in reader on top of Apple's native `Compression` framework, and networking uses `URLSession`.
90
90
 
91
91
  ## Compatibility
92
92
 
@@ -335,6 +335,7 @@ CapacitorUpdater can be configured with these options:
335
335
  | **`localApi`** | <code>string</code> | Configure the CLI to use a local api for testing. | <code>undefined</code> | 6.3.3 |
336
336
  | **`localApiFiles`** | <code>string</code> | Configure the CLI to use a local file api for testing. | <code>undefined</code> | 6.3.3 |
337
337
  | **`allowModifyUrl`** | <code>boolean</code> | Allow the plugin to modify the updateUrl, statsUrl and channelUrl dynamically from the JavaScript side. | <code>false</code> | 5.4.0 |
338
+ | **`allowHttpsToHttpRedirect`** | <code>boolean</code> | Allow the plugin to follow redirects from HTTPS to plain HTTP for its own network requests (update checks, stats, channel calls, and bundle/manifest downloads). Blocked by default so a redirect can never downgrade updater traffic to an unencrypted connection. Only enable this if your self-hosted update server or CDN must redirect to an HTTP URL. Direct HTTP URLs (for example `localApi` during development) are not affected. Only available for Android and iOS. | <code>false</code> | 8.52.0 |
338
339
  | **`allowModifyAppId`** | <code>boolean</code> | Allow the plugin to modify the appId dynamically from the JavaScript side. | <code>false</code> | 7.14.0 |
339
340
  | **`allowManualBundleError`** | <code>boolean</code> | Allow marking bundles as errored from JavaScript while using manual update flows. When enabled, {@link CapacitorUpdaterPlugin.setBundleError} can change a bundle status to `error`. | <code>false</code> | 7.20.0 |
340
341
  | **`allowPreview`** | <code>boolean</code> | Allow JavaScript to start a native preview session and temporarily request updates for another app id. This is intended for trusted container apps that implement Expo Go-style preview flows. Only available for Android and iOS. | <code>false</code> | 8.47.0 |
@@ -383,6 +384,7 @@ In `capacitor.config.json`:
383
384
  "localApi": undefined,
384
385
  "localApiFiles": undefined,
385
386
  "allowModifyUrl": undefined,
387
+ "allowHttpsToHttpRedirect": undefined,
386
388
  "allowModifyAppId": undefined,
387
389
  "allowManualBundleError": undefined,
388
390
  "allowPreview": undefined,
@@ -437,6 +439,7 @@ const config: CapacitorConfig = {
437
439
  localApi: undefined,
438
440
  localApiFiles: undefined,
439
441
  allowModifyUrl: undefined,
442
+ allowHttpsToHttpRedirect: undefined,
440
443
  allowModifyAppId: undefined,
441
444
  allowManualBundleError: undefined,
442
445
  allowPreview: undefined,
@@ -58,10 +58,8 @@ dependencies {
58
58
  implementation fileTree(dir: 'libs', include: ['*.jar'])
59
59
  implementation project(':capacitor-android')
60
60
  implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
61
- implementation 'io.github.g00fy2:versioncompare:1.5.0'
62
61
  // Play Core library for in-app updates
63
62
  implementation 'com.google.android.play:app-update:2.1.0'
64
- implementation 'com.google.android.play:app-update-ktx:2.1.0'
65
63
  testImplementation "junit:junit:$junitVersion"
66
64
  testImplementation 'org.mockito:mockito-core:5.23.0'
67
65
  testImplementation 'org.json:json:20250517'
@@ -48,12 +48,10 @@ import com.google.android.play.core.install.InstallStateUpdatedListener;
48
48
  import com.google.android.play.core.install.model.AppUpdateType;
49
49
  import com.google.android.play.core.install.model.InstallStatus;
50
50
  import com.google.android.play.core.install.model.UpdateAvailability;
51
- import io.github.g00fy2.versioncompare.Version;
52
51
  import java.io.ByteArrayOutputStream;
53
52
  import java.io.File;
54
53
  import java.io.IOException;
55
54
  import java.io.InputStream;
56
- import java.net.HttpURLConnection;
57
55
  import java.net.MalformedURLException;
58
56
  import java.net.URL;
59
57
  import java.nio.charset.StandardCharsets;
@@ -75,7 +73,11 @@ import java.util.concurrent.Semaphore;
75
73
  import java.util.concurrent.TimeUnit;
76
74
  import java.util.concurrent.TimeoutException;
77
75
  import java.util.concurrent.atomic.AtomicReference;
78
- // Removed OkHttpClient and Protocol imports - using shared client in DownloadService instead
76
+ import okhttp3.HttpUrl;
77
+ import okhttp3.OkHttpClient;
78
+ import okhttp3.Request;
79
+ import okhttp3.Response;
80
+ import okhttp3.ResponseBody;
79
81
  import org.json.JSONArray;
80
82
  import org.json.JSONException;
81
83
  import org.json.JSONObject;
@@ -146,7 +148,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
146
148
  static final int APPLICATION_EXIT_REASON_USER_REQUESTED = 10;
147
149
  static final int APPLICATION_EXIT_REASON_DEPENDENCY_DIED = 12;
148
150
 
149
- private final String pluginVersion = "8.51.25";
151
+ private final String pluginVersion = "8.52.0";
150
152
  private static final String DELAY_CONDITION_PREFERENCES = "";
151
153
 
152
154
  private SharedPreferences.Editor editor;
@@ -222,6 +224,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
222
224
  private volatile Thread appReadyCheck;
223
225
  // When true, sendReadyToJs should wait for notifyAppReady before hiding splash.
224
226
  private volatile boolean pendingNotifyAppReadyWait = false;
227
+ // Armed only after a reload. The next document stamps this generation into notifyAppReady.
228
+ private final Object readyGuardLock = new Object();
229
+ private volatile int readyGeneration = 0;
230
+ private volatile boolean readyGuardArmed = false;
225
231
  private volatile int pendingNotifyAppReadyPhase = -1;
226
232
  private volatile long downloadStartTimeMs = 0;
227
233
  private static final long DOWNLOAD_TIMEOUT_MS = 600000; // 10 minute timeout
@@ -710,6 +716,11 @@ public class CapacitorUpdaterPlugin extends Plugin {
710
716
  CapacitorUpdaterPlugin.this.scheduleDirectUpdateFinish(latest);
711
717
  }
712
718
 
719
+ @Override
720
+ public void backgroundDownloadSettled(final BundleInfo bundle, final String status) {
721
+ CapacitorUpdaterPlugin.this.emitLaunchDownloadReady(bundle, status);
722
+ }
723
+
713
724
  @Override
714
725
  public void notifyListeners(final String id, final Map<String, Object> res) {
715
726
  if (activity != null) {
@@ -727,7 +738,6 @@ public class CapacitorUpdaterPlugin extends Plugin {
727
738
  this.implementation.CAP_SERVER_PATH = WebView.CAP_SERVER_PATH;
728
739
  this.implementation.pluginVersion = this.pluginVersion;
729
740
  this.implementation.versionCode = this.getVersionCode(pInfo);
730
- // Removed unused OkHttpClient creation - using shared client in DownloadService instead
731
741
  this.currentVersionNative = new Version(this.getConfig().getString("version", pInfo.versionName));
732
742
  this.currentBuildVersion = this.getVersionCode(pInfo);
733
743
  this.delayUpdateUtils = new DelayUpdateUtils(this.prefs, this.editor, this.currentVersionNative, logger);
@@ -886,6 +896,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
886
896
  long responseTimeoutMillis = responseTimeoutSeconds > 0 ? (long) responseTimeoutSeconds * 1000L : 20_000L;
887
897
  this.implementation.timeout = (int) Math.min(Integer.MAX_VALUE, responseTimeoutMillis);
888
898
  DownloadService.applyHttpTimeouts(this.implementation.timeout);
899
+ DownloadService.setAllowHttpsToHttpRedirect(this.getConfig().getBoolean("allowHttpsToHttpRedirect", false));
889
900
  this.shakeMenuEnabled = this.getConfig().getBoolean("shakeMenu", false);
890
901
  this.shakeChannelSelectorEnabled = this.getConfig().getBoolean("allowShakeChannelSelector", false);
891
902
  this.shakeMenuGesture = normalizedShakeMenuGesture(this.getConfig().getString("shakeMenuGesture", SHAKE_MENU_GESTURE_SHAKE));
@@ -2336,6 +2347,24 @@ public class CapacitorUpdaterPlugin extends Plugin {
2336
2347
  this.endBackGroundTaskWithNotif("test", current.getVersionName(), current, false, plannedDirectUpdate);
2337
2348
  }
2338
2349
 
2350
+ void emitLaunchDownloadReady(final BundleInfo bundle, final String status) {
2351
+ final BundleInfo readyBundle = bundle != null ? bundle : this.implementation.getCurrentBundle();
2352
+ if (readyBundle == null) {
2353
+ return;
2354
+ }
2355
+ this.endBackGroundTaskWithNotif(
2356
+ status,
2357
+ readyBundle.getVersionName(),
2358
+ readyBundle,
2359
+ false,
2360
+ false,
2361
+ "download_fail",
2362
+ "downloadFailed",
2363
+ false,
2364
+ false
2365
+ );
2366
+ }
2367
+
2339
2368
  void scheduleDirectUpdateFinish(final BundleInfo latest) {
2340
2369
  startNewThread(() -> {
2341
2370
  try {
@@ -2925,6 +2954,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2925
2954
  }
2926
2955
 
2927
2956
  private void applyCurrentBundleToBridge() {
2957
+ this.stampReadyGenerationBeforeReload();
2928
2958
  final String path = this.implementation.getCurrentBundlePath();
2929
2959
  final boolean usingBuiltin = this.implementation.isUsingBuiltin();
2930
2960
  if (this.keepUrlPathAfterReload) {
@@ -3862,17 +3892,22 @@ public class CapacitorUpdaterPlugin extends Plugin {
3862
3892
  }
3863
3893
 
3864
3894
  private JSONObject fetchPreviewPayload(final String payloadUrl) throws IOException, JSONException {
3865
- final HttpURLConnection connection = (HttpURLConnection) new URL(payloadUrl).openConnection();
3866
- connection.setRequestMethod("GET");
3867
- connection.setRequestProperty("Accept", "application/json");
3868
- connection.setConnectTimeout(30000);
3869
- connection.setReadTimeout(60000);
3870
-
3871
- try {
3872
- final int statusCode = connection.getResponseCode();
3873
- final String body = this.readResponseBody(
3874
- statusCode >= 200 && statusCode < 300 ? connection.getInputStream() : connection.getErrorStream()
3875
- );
3895
+ final HttpUrl url = payloadUrl != null ? HttpUrl.parse(payloadUrl) : null;
3896
+ if (url == null) {
3897
+ throw new MalformedURLException("Expected an http or https preview payload URL");
3898
+ }
3899
+ // Shared client: no cookies, Capgo User-Agent.
3900
+ final OkHttpClient client = DownloadService.sharedClient
3901
+ .newBuilder()
3902
+ .connectTimeout(30, TimeUnit.SECONDS)
3903
+ .readTimeout(60, TimeUnit.SECONDS)
3904
+ .build();
3905
+ final Request request = new Request.Builder().url(url).get().header("Accept", "application/json").build();
3906
+
3907
+ try (Response response = client.newCall(request).execute()) {
3908
+ final int statusCode = response.code();
3909
+ final ResponseBody responseBody = response.body();
3910
+ final String body = this.readResponseBody(responseBody != null ? responseBody.byteStream() : null);
3876
3911
  final JSONObject payload = new JSONObject(body);
3877
3912
  if (statusCode < 200 || statusCode >= 300) {
3878
3913
  throw new IOException(
@@ -3880,8 +3915,6 @@ public class CapacitorUpdaterPlugin extends Plugin {
3880
3915
  );
3881
3916
  }
3882
3917
  return payload;
3883
- } finally {
3884
- connection.disconnect();
3885
3918
  }
3886
3919
  }
3887
3920
 
@@ -4490,10 +4523,120 @@ public class CapacitorUpdaterPlugin extends Plugin {
4490
4523
  );
4491
4524
  }
4492
4525
 
4526
+ static boolean shouldAcceptReadyCall(
4527
+ final boolean guardArmed,
4528
+ final int expectedGeneration,
4529
+ final boolean hasGeneration,
4530
+ final int reportedGeneration
4531
+ ) {
4532
+ if (!guardArmed) {
4533
+ return true;
4534
+ }
4535
+ return hasGeneration && reportedGeneration == expectedGeneration;
4536
+ }
4537
+
4538
+ static String readyGenerationScript(final int generation) {
4539
+ // Wrap Capacitor.nativePromise, not the plugin proxy. registerPlugin's get trap
4540
+ // ignores assignments to notifyAppReady. Each document keeps its own generation.
4541
+ return (
4542
+ "(function(){window.__CAPGO_READY_GEN=" +
4543
+ generation +
4544
+ ";if(window.__capgoReadyBridge)return;" +
4545
+ "function arm(){var cap=window.Capacitor;if(!cap||typeof cap.nativePromise!=='function'||cap.__capgoNativePromise)return false;" +
4546
+ "var orig=cap.nativePromise.bind(cap);" +
4547
+ "cap.nativePromise=function(pluginName,methodName,options){if(pluginName==='CapacitorUpdater'&&methodName==='notifyAppReady'){" +
4548
+ "var next={};if(options&&typeof options==='object'){for(var k in options){if(Object.prototype.hasOwnProperty.call(options,k))next[k]=options[k];}}" +
4549
+ "next.loadGeneration=window.__CAPGO_READY_GEN;options=next;}return orig(pluginName,methodName,options);};" +
4550
+ "cap.__capgoNativePromise=true;window.__capgoReadyBridge=true;return true;}" +
4551
+ "if(!arm()){var n=0;var t=setInterval(function(){if(arm()||++n>100)clearInterval(t);},20);}" +
4552
+ "})();"
4553
+ );
4554
+ }
4555
+
4556
+ private int armReadyGuard() {
4557
+ synchronized (this.readyGuardLock) {
4558
+ this.readyGeneration = this.readyGeneration + 1;
4559
+ this.readyGuardArmed = true;
4560
+ return this.readyGeneration;
4561
+ }
4562
+ }
4563
+
4564
+ private void disarmReadyGuard(final int generation) {
4565
+ synchronized (this.readyGuardLock) {
4566
+ if (this.readyGeneration == generation) {
4567
+ this.readyGuardArmed = false;
4568
+ logger.warn("Could not stamp notifyAppReady for the next page. Readiness guard disabled for this reload.");
4569
+ }
4570
+ }
4571
+ }
4572
+
4573
+ private void stampReadyGenerationBeforeReload() {
4574
+ final int generation = this.armReadyGuard();
4575
+ final android.webkit.WebView webView = this.bridge != null ? this.bridge.getWebView() : null;
4576
+ if (webView == null) {
4577
+ this.disarmReadyGuard(generation);
4578
+ return;
4579
+ }
4580
+ webView.post(() -> {
4581
+ if (!this.installReadyGenerationScript(webView, generation)) {
4582
+ this.disarmReadyGuard(generation);
4583
+ }
4584
+ });
4585
+ }
4586
+
4587
+ private boolean installReadyGenerationScript(final android.webkit.WebView webView, final int generation) {
4588
+ try {
4589
+ final Class<?> webViewFeature = Class.forName("androidx.webkit.WebViewFeature");
4590
+ final String feature = (String) webViewFeature.getField("DOCUMENT_START_SCRIPT").get(null);
4591
+ final Boolean supported = (Boolean) webViewFeature.getMethod("isFeatureSupported", String.class).invoke(null, feature);
4592
+ if (!Boolean.TRUE.equals(supported)) {
4593
+ return false;
4594
+ }
4595
+ if (this.bridge == null || this.bridge.getAppUrl() == null) {
4596
+ return false;
4597
+ }
4598
+ final String allowedOrigin = Uri.parse(this.bridge.getAppUrl())
4599
+ .buildUpon()
4600
+ .path(null)
4601
+ .fragment(null)
4602
+ .clearQuery()
4603
+ .build()
4604
+ .toString();
4605
+ final Class<?> webViewCompat = Class.forName("androidx.webkit.WebViewCompat");
4606
+ webViewCompat
4607
+ .getMethod("addDocumentStartJavaScript", android.webkit.WebView.class, String.class, Set.class)
4608
+ .invoke(null, webView, readyGenerationScript(generation), java.util.Collections.singleton(allowedOrigin));
4609
+ return true;
4610
+ } catch (final Exception e) {
4611
+ logger.warn("Unable to stamp notifyAppReady generation: " + e.getMessage());
4612
+ return false;
4613
+ }
4614
+ }
4615
+
4616
+ private boolean acceptsReadyCall(final PluginCall call) {
4617
+ if (!this.readyGuardArmed) {
4618
+ return true;
4619
+ }
4620
+ final JSObject data = call.getData();
4621
+ final boolean hasGeneration = data != null && data.has("loadGeneration");
4622
+ final int reported = hasGeneration ? data.optInt("loadGeneration", -1) : -1;
4623
+ return shouldAcceptReadyCall(this.readyGuardArmed, this.readyGeneration, hasGeneration, reported);
4624
+ }
4625
+
4493
4626
  @PluginMethod
4494
4627
  public void notifyAppReady(final PluginCall call) {
4495
4628
  ensureBridgeSet();
4496
4629
  try {
4630
+ if (!this.acceptsReadyCall(call)) {
4631
+ logger.info("Ignoring notifyAppReady from a page that is no longer current");
4632
+ final BundleInfo current = this.implementation.getCurrentBundle();
4633
+ final JSObject ignored = new JSObject();
4634
+ if (current != null) {
4635
+ ignored.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
4636
+ }
4637
+ call.resolve(ignored);
4638
+ return;
4639
+ }
4497
4640
  final BundleInfo bundle = this.implementation.getCurrentBundle();
4498
4641
  this.implementation.setSuccess(bundle, this.autoDeletePrevious);
4499
4642
  this.reportAppLaunchReady(bundle);
@@ -5195,20 +5338,28 @@ public class CapacitorUpdaterPlugin extends Plugin {
5195
5338
  this.performReset(true, false, true);
5196
5339
  if (CapacitorUpdaterPlugin.this.autoDeleteFailed && !current.isBuiltin()) {
5197
5340
  final String failedId = current.getId();
5198
- final String failedVersion = current.getVersionName();
5199
- logger.info("Deleting failing bundle: " + failedVersion);
5200
- // Mark before async work so kill/OOM still resumes via drainPendingDeletes.
5201
- CapacitorUpdaterPlugin.this.implementation.saveBundleInfo(failedId, current.setStatus(BundleStatus.DELETING));
5202
- startNewThread(() -> {
5203
- try {
5204
- final Boolean res = CapacitorUpdaterPlugin.this.implementation.delete(failedId, false, false);
5205
- if (Boolean.TRUE.equals(res)) {
5206
- logger.info("Failed bundle deleted: " + failedVersion);
5341
+ final BundleInfo latest = this.implementation.getBundleInfo(failedId);
5342
+ final boolean stillCurrent = failedId.equals(this.implementation.getCurrentBundle().getId());
5343
+ // performReset waits for notifyAppReady. Resetting onto this same bundle writes
5344
+ // SUCCESS before we get here, so the pre-reset snapshot must not become DELETING.
5345
+ if (latest == null || BundleStatus.ERROR != latest.getStatus() || stillCurrent) {
5346
+ logger.info("Skip deleting bundle " + failedId + " after reset");
5347
+ } else {
5348
+ final String failedVersion = latest.getVersionName();
5349
+ logger.info("Deleting failing bundle: " + failedVersion);
5350
+ // Mark before async work so kill/OOM still resumes via drainPendingDeletes.
5351
+ CapacitorUpdaterPlugin.this.implementation.saveBundleInfo(failedId, latest.setStatus(BundleStatus.DELETING));
5352
+ startNewThread(() -> {
5353
+ try {
5354
+ final Boolean res = CapacitorUpdaterPlugin.this.implementation.delete(failedId, false, false);
5355
+ if (Boolean.TRUE.equals(res)) {
5356
+ logger.info("Failed bundle deleted: " + failedVersion);
5357
+ }
5358
+ } catch (final IOException e) {
5359
+ logger.error("Failed to delete failed bundle: " + failedVersion + " " + e.getMessage());
5207
5360
  }
5208
- } catch (final IOException e) {
5209
- logger.error("Failed to delete failed bundle: " + failedVersion + " " + e.getMessage());
5210
- }
5211
- });
5361
+ });
5362
+ }
5212
5363
  }
5213
5364
  } else {
5214
5365
  logger.info("notifyAppReady was called. This is fine: " + current.getId());
@@ -255,8 +255,36 @@ public class CapgoUpdater {
255
255
 
256
256
  void directUpdateFinish(final BundleInfo latest) {}
257
257
 
258
+ /** Launch downloads have no waiter. The plugin emits appReady from here when WorkManager settles. */
259
+ void backgroundDownloadSettled(final BundleInfo bundle, final String status) {}
260
+
258
261
  void notifyListeners(final String id, final Map<String, Object> res) {}
259
262
 
263
+ static boolean shouldNotifyLaunchDownloadReady(
264
+ final boolean awaitedByCaller,
265
+ final boolean success,
266
+ final boolean directInstall,
267
+ final boolean previewSession
268
+ ) {
269
+ if (awaitedByCaller) {
270
+ return false;
271
+ }
272
+ if (!success) {
273
+ return true;
274
+ }
275
+ return !directInstall && !previewSession;
276
+ }
277
+
278
+ static String launchDownloadReadyStatus(final boolean success, final boolean setNext) {
279
+ if (!success) {
280
+ return "Error downloading file";
281
+ }
282
+ if (setNext) {
283
+ return "update downloaded, will install next background";
284
+ }
285
+ return "update downloaded, autoUpdate onlyDownload";
286
+ }
287
+
260
288
  public String randomString() {
261
289
  final StringBuilder sb = new StringBuilder(10);
262
290
  for (int i = 0; i < 10; i++) sb.append(AB.charAt(rnd.nextInt(AB.length())));
@@ -703,12 +731,28 @@ public class CapgoUpdater {
703
731
  }
704
732
  }
705
733
 
734
+ private void notifyLaunchDownloadReady(
735
+ final boolean awaitedByCaller,
736
+ final boolean success,
737
+ final boolean directInstall,
738
+ final boolean previewSession,
739
+ final boolean setNext,
740
+ final BundleInfo bundle
741
+ ) {
742
+ if (!shouldNotifyLaunchDownloadReady(awaitedByCaller, success, directInstall, previewSession)) {
743
+ return;
744
+ }
745
+ final BundleInfo readyBundle = bundle != null ? bundle : this.getCurrentBundle();
746
+ this.backgroundDownloadSettled(readyBundle, launchDownloadReadyStatus(success, setNext));
747
+ }
748
+
706
749
  private void observeWorkProgress(Context context, String id, boolean setNext) {
707
750
  if (!(context instanceof LifecycleOwner)) {
708
751
  logger.error("Context is not a LifecycleOwner, cannot observe work progress");
709
752
  return;
710
753
  }
711
754
 
755
+ final AtomicBoolean terminalHandled = new AtomicBoolean(false);
712
756
  activity.runOnUiThread(() -> {
713
757
  WorkManager.getInstance(context)
714
758
  .getWorkInfosByTagLiveData(id)
@@ -724,6 +768,7 @@ public class CapgoUpdater {
724
768
  notifyDownload(id, percent);
725
769
  break;
726
770
  case SUCCEEDED:
771
+ if (!terminalHandled.compareAndSet(false, true)) break;
727
772
  logger.info("Download succeeded: " + workInfo.getState());
728
773
  Data outputData = workInfo.getOutputData();
729
774
  String dest = outputData.getString(DownloadService.FILEDEST);
@@ -733,6 +778,10 @@ public class CapgoUpdater {
733
778
  boolean isManifest = outputData.getBoolean(DownloadService.IS_MANIFEST, false);
734
779
 
735
780
  io.execute(() -> {
781
+ // finishDownload clears directUpdate, so read the install plan first.
782
+ final boolean directInstall =
783
+ setNext && Boolean.TRUE.equals(CapgoUpdater.this.directUpdate) && !CapgoUpdater.this.previewSession;
784
+ final boolean previewSession = CapgoUpdater.this.previewSession;
736
785
  boolean success = finishDownload(id, dest, version, sessionKey, checksum, setNext, isManifest);
737
786
  BundleInfo resultBundle;
738
787
  if (!success) {
@@ -759,14 +808,18 @@ public class CapgoUpdater {
759
808
  resultBundle = getBundleInfo(id);
760
809
  }
761
810
 
762
- // Complete the future if it exists
811
+ // Complete the future if it exists. download() waits on it.
812
+ // downloadBackground does not, so the launch check must emit appReady here.
763
813
  CompletableFuture<BundleInfo> future = downloadFutures.remove(id);
764
814
  if (future != null) {
765
815
  future.complete(resultBundle);
766
816
  }
817
+ final BundleInfo readyBundle = success && setNext ? resultBundle : null;
818
+ notifyLaunchDownloadReady(future != null, success, directInstall, previewSession, setNext, readyBundle);
767
819
  });
768
820
  break;
769
821
  case FAILED:
822
+ if (!terminalHandled.compareAndSet(false, true)) break;
770
823
  Data failedData = workInfo.getOutputData();
771
824
  String error = failedData.getString(DownloadService.ERROR);
772
825
  logger.error("Download failed");
@@ -801,9 +854,11 @@ public class CapgoUpdater {
801
854
  if (failedFuture != null) {
802
855
  failedFuture.complete(failedBundle);
803
856
  }
857
+ notifyLaunchDownloadReady(failedFuture != null, false, false, false, false, null);
804
858
  });
805
859
  break;
806
860
  case CANCELLED:
861
+ if (!terminalHandled.compareAndSet(false, true)) break;
807
862
  DataManager.getInstance().clearManifest(id);
808
863
  CompletableFuture<BundleInfo> cancelledFuture = downloadFutures.remove(id);
809
864
  if (cancelledFuture != null) {
@@ -3469,6 +3524,10 @@ public class CapgoUpdater {
3469
3524
  if (!newBundle.isBuiltin() && !this.bundleExists(next)) {
3470
3525
  return false;
3471
3526
  }
3527
+ if (next.equals(this.getCurrentBundleId()) && BundleStatus.SUCCESS == newBundle.getStatus()) {
3528
+ logger.info("Bundle " + next + " is already the current successful bundle. Skip next().");
3529
+ return true;
3530
+ }
3472
3531
  this.editor.putString(NEXT_VERSION, next);
3473
3532
  this.setBundleStatus(next, BundleStatus.PENDING);
3474
3533
  bundleToNotify = newBundle;
@@ -1,7 +1,6 @@
1
1
  package ee.forgr.capacitor_updater;
2
2
 
3
3
  import android.content.SharedPreferences;
4
- import io.github.g00fy2.versioncompare.Version;
5
4
  import java.text.ParsePosition;
6
5
  import java.text.SimpleDateFormat;
7
6
  import java.util.ArrayList;