@capgo/capacitor-updater 8.51.25 → 8.52.1

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.1";
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());
@@ -150,6 +150,9 @@ public class CapgoUpdater {
150
150
  }
151
151
 
152
152
  private final Map<String, CompletableFuture<BundleInfo>> downloadFutures = new ConcurrentHashMap<>();
153
+ // Versions whose WorkManager download is observed by this process. WorkManager persists work across a
154
+ // process kill, but the observer that finishes the download does not survive it.
155
+ private final Set<String> observedDownloadVersions = ConcurrentHashMap.newKeySet();
153
156
  private final ExecutorService io = Executors.newSingleThreadExecutor();
154
157
 
155
158
  public CapgoUpdater(Logger logger) {
@@ -255,8 +258,49 @@ public class CapgoUpdater {
255
258
 
256
259
  void directUpdateFinish(final BundleInfo latest) {}
257
260
 
261
+ /** Launch downloads have no waiter. The plugin emits appReady from here when WorkManager settles. */
262
+ void backgroundDownloadSettled(final BundleInfo bundle, final String status) {}
263
+
258
264
  void notifyListeners(final String id, final Map<String, Object> res) {}
259
265
 
266
+ static boolean shouldNotifyLaunchDownloadReady(
267
+ final boolean awaitedByCaller,
268
+ final boolean success,
269
+ final boolean directInstall,
270
+ final boolean previewSession
271
+ ) {
272
+ if (awaitedByCaller) {
273
+ return false;
274
+ }
275
+ if (!success) {
276
+ return true;
277
+ }
278
+ return !directInstall && !previewSession;
279
+ }
280
+
281
+ static boolean shouldRestartOrphanedDownload(final boolean observedByThisProcess) {
282
+ return !observedByThisProcess;
283
+ }
284
+
285
+ static boolean shouldReleaseLaunchWhileRetrying(
286
+ final boolean awaitedByCaller,
287
+ final boolean setNext,
288
+ final boolean directUpdate,
289
+ final boolean previewSession
290
+ ) {
291
+ return !awaitedByCaller && setNext && directUpdate && !previewSession;
292
+ }
293
+
294
+ static String launchDownloadReadyStatus(final boolean success, final boolean setNext) {
295
+ if (!success) {
296
+ return "Error downloading file";
297
+ }
298
+ if (setNext) {
299
+ return "update downloaded, will install next background";
300
+ }
301
+ return "update downloaded, autoUpdate onlyDownload";
302
+ }
303
+
260
304
  public String randomString() {
261
305
  final StringBuilder sb = new StringBuilder(10);
262
306
  for (int i = 0; i < 10; i++) sb.append(AB.charAt(rnd.nextInt(AB.length())));
@@ -703,12 +747,29 @@ public class CapgoUpdater {
703
747
  }
704
748
  }
705
749
 
706
- private void observeWorkProgress(Context context, String id, boolean setNext) {
750
+ private void notifyLaunchDownloadReady(
751
+ final boolean awaitedByCaller,
752
+ final boolean success,
753
+ final boolean directInstall,
754
+ final boolean previewSession,
755
+ final boolean setNext,
756
+ final BundleInfo bundle
757
+ ) {
758
+ if (!shouldNotifyLaunchDownloadReady(awaitedByCaller, success, directInstall, previewSession)) {
759
+ return;
760
+ }
761
+ final BundleInfo readyBundle = bundle != null ? bundle : this.getCurrentBundle();
762
+ this.backgroundDownloadSettled(readyBundle, launchDownloadReadyStatus(success, setNext));
763
+ }
764
+
765
+ private void observeWorkProgress(Context context, String id, String observedVersion, boolean setNext) {
707
766
  if (!(context instanceof LifecycleOwner)) {
708
767
  logger.error("Context is not a LifecycleOwner, cannot observe work progress");
709
768
  return;
710
769
  }
711
770
 
771
+ final AtomicBoolean terminalHandled = new AtomicBoolean(false);
772
+ final AtomicBoolean launchReleasedWhileRetrying = new AtomicBoolean(false);
712
773
  activity.runOnUiThread(() -> {
713
774
  WorkManager.getInstance(context)
714
775
  .getWorkInfosByTagLiveData(id)
@@ -719,11 +780,33 @@ public class CapgoUpdater {
719
780
  Data progress = workInfo.getProgress();
720
781
 
721
782
  switch (workInfo.getState()) {
783
+ case ENQUEUED:
784
+ // A run attempt already happened, so WorkManager scheduled a retry (network lost midway,
785
+ // 5xx, ...). Retries back off without limit, so a direct install must not keep the launch
786
+ // (and its splashscreen) blocked until the network comes back.
787
+ if (
788
+ workInfo.getRunAttemptCount() > 0 &&
789
+ shouldReleaseLaunchWhileRetrying(
790
+ downloadFutures.containsKey(id),
791
+ setNext,
792
+ Boolean.TRUE.equals(CapgoUpdater.this.directUpdate),
793
+ CapgoUpdater.this.previewSession
794
+ ) &&
795
+ launchReleasedWhileRetrying.compareAndSet(false, true)
796
+ ) {
797
+ logger.warn("Direct update download is retrying, continuing launch on the current bundle");
798
+ // Same fallback as the autoSplashscreen timeout: a later success installs on next background.
799
+ CapgoUpdater.this.directUpdate = false;
800
+ io.execute(() -> backgroundDownloadSettled(getCurrentBundle(), launchDownloadReadyStatus(false, false)));
801
+ }
802
+ break;
722
803
  case RUNNING:
723
804
  int percent = progress.getInt(DownloadService.PERCENT, 0);
724
805
  notifyDownload(id, percent);
725
806
  break;
726
807
  case SUCCEEDED:
808
+ if (!terminalHandled.compareAndSet(false, true)) break;
809
+ observedDownloadVersions.remove(observedVersion);
727
810
  logger.info("Download succeeded: " + workInfo.getState());
728
811
  Data outputData = workInfo.getOutputData();
729
812
  String dest = outputData.getString(DownloadService.FILEDEST);
@@ -733,6 +816,10 @@ public class CapgoUpdater {
733
816
  boolean isManifest = outputData.getBoolean(DownloadService.IS_MANIFEST, false);
734
817
 
735
818
  io.execute(() -> {
819
+ // finishDownload clears directUpdate, so read the install plan first.
820
+ final boolean directInstall =
821
+ setNext && Boolean.TRUE.equals(CapgoUpdater.this.directUpdate) && !CapgoUpdater.this.previewSession;
822
+ final boolean previewSession = CapgoUpdater.this.previewSession;
736
823
  boolean success = finishDownload(id, dest, version, sessionKey, checksum, setNext, isManifest);
737
824
  BundleInfo resultBundle;
738
825
  if (!success) {
@@ -759,14 +846,21 @@ public class CapgoUpdater {
759
846
  resultBundle = getBundleInfo(id);
760
847
  }
761
848
 
762
- // Complete the future if it exists
849
+ // Complete the future if it exists. download() waits on it.
850
+ // downloadBackground does not, so the launch check must emit appReady here.
763
851
  CompletableFuture<BundleInfo> future = downloadFutures.remove(id);
764
852
  if (future != null) {
765
853
  future.complete(resultBundle);
766
854
  }
855
+ final BundleInfo readyBundle = success && setNext ? resultBundle : null;
856
+ if (!launchReleasedWhileRetrying.get()) {
857
+ notifyLaunchDownloadReady(future != null, success, directInstall, previewSession, setNext, readyBundle);
858
+ }
767
859
  });
768
860
  break;
769
861
  case FAILED:
862
+ if (!terminalHandled.compareAndSet(false, true)) break;
863
+ observedDownloadVersions.remove(observedVersion);
770
864
  Data failedData = workInfo.getOutputData();
771
865
  String error = failedData.getString(DownloadService.ERROR);
772
866
  logger.error("Download failed");
@@ -801,9 +895,14 @@ public class CapgoUpdater {
801
895
  if (failedFuture != null) {
802
896
  failedFuture.complete(failedBundle);
803
897
  }
898
+ if (!launchReleasedWhileRetrying.get()) {
899
+ notifyLaunchDownloadReady(failedFuture != null, false, false, false, false, null);
900
+ }
804
901
  });
805
902
  break;
806
903
  case CANCELLED:
904
+ if (!terminalHandled.compareAndSet(false, true)) break;
905
+ observedDownloadVersions.remove(observedVersion);
807
906
  DataManager.getInstance().clearManifest(id);
808
907
  CompletableFuture<BundleInfo> cancelledFuture = downloadFutures.remove(id);
809
908
  if (cancelledFuture != null) {
@@ -829,7 +928,8 @@ public class CapgoUpdater {
829
928
  logger.error("Activity is null, cannot observe work progress");
830
929
  return;
831
930
  }
832
- observeWorkProgress(this.activity, id, setNext);
931
+ observedDownloadVersions.add(version);
932
+ observeWorkProgress(this.activity, id, version, setNext);
833
933
 
834
934
  if (manifest != null) {
835
935
  DataManager.getInstance().setManifest(id, manifest);
@@ -1479,6 +1579,13 @@ public class CapgoUpdater {
1479
1579
  return;
1480
1580
  }
1481
1581
  logger.info("Retrying failed download for version: " + version);
1582
+ } else if (shouldRestartOrphanedDownload(observedDownloadVersions.contains(version))) {
1583
+ // Left over from a killed process: nothing would finish it or release the launch, so start over.
1584
+ if (!DownloadWorkerManager.cancelVersionDownloadAndAwait(this.activity, version)) {
1585
+ logger.error("Failed to cancel orphaned download before restarting it");
1586
+ return;
1587
+ }
1588
+ logger.info("Restarting download orphaned by a previous process for version: " + version);
1482
1589
  } else {
1483
1590
  logger.info("Version already downloading: " + version);
1484
1591
  return;
@@ -3469,6 +3576,10 @@ public class CapgoUpdater {
3469
3576
  if (!newBundle.isBuiltin() && !this.bundleExists(next)) {
3470
3577
  return false;
3471
3578
  }
3579
+ if (next.equals(this.getCurrentBundleId()) && BundleStatus.SUCCESS == newBundle.getStatus()) {
3580
+ logger.info("Bundle " + next + " is already the current successful bundle. Skip next().");
3581
+ return true;
3582
+ }
3472
3583
  this.editor.putString(NEXT_VERSION, next);
3473
3584
  this.setBundleStatus(next, BundleStatus.PENDING);
3474
3585
  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;