@capgo/capacitor-updater 7.50.2 → 7.51.16

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.
Files changed (32) hide show
  1. package/CapgoCapacitorUpdater.podspec +0 -2
  2. package/Package.swift +4 -5
  3. package/README.md +53 -48
  4. package/android/build.gradle +6 -6
  5. package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +29 -2
  6. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +7 -3
  7. package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +1 -0
  8. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +427 -116
  9. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1108 -182
  10. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +102 -31
  11. package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +23 -7
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +2 -2
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +11 -0
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +513 -201
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +103 -3
  16. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
  17. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +115 -133
  18. package/dist/docs.json +32 -8
  19. package/dist/esm/definitions.d.ts +41 -17
  20. package/dist/esm/definitions.js.map +1 -1
  21. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +124 -0
  22. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +9 -1
  23. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
  24. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +806 -92
  25. package/ios/Sources/CapacitorUpdaterPlugin/CapgoRawRsa.swift +36 -0
  26. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1020 -270
  27. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
  28. package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +73 -251
  29. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
  30. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
  31. package/package.json +13 -8
  32. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -39
@@ -1,6 +1,7 @@
1
1
  package ee.forgr.capacitor_updater;
2
2
 
3
3
  import android.content.Context;
4
+ import android.os.Build;
4
5
  import androidx.work.BackoffPolicy;
5
6
  import androidx.work.Configuration;
6
7
  import androidx.work.Constraints;
@@ -8,9 +9,18 @@ import androidx.work.Data;
8
9
  import androidx.work.ExistingWorkPolicy;
9
10
  import androidx.work.NetworkType;
10
11
  import androidx.work.OneTimeWorkRequest;
12
+ import androidx.work.OutOfQuotaPolicy;
13
+ import androidx.work.WorkInfo;
11
14
  import androidx.work.WorkManager;
12
15
  import androidx.work.WorkRequest;
16
+ import java.util.HashSet;
17
+ import java.util.List;
18
+ import java.util.Set;
19
+ import java.util.concurrent.ExecutorService;
20
+ import java.util.concurrent.Executors;
21
+ import java.util.concurrent.Future;
13
22
  import java.util.concurrent.TimeUnit;
23
+ import java.util.concurrent.TimeoutException;
14
24
 
15
25
  public class DownloadWorkerManager {
16
26
 
@@ -21,6 +31,7 @@ public class DownloadWorkerManager {
21
31
  }
22
32
 
23
33
  private static volatile boolean isInitialized = false;
34
+ private static final ExecutorService cancelExecutor = Executors.newSingleThreadExecutor();
24
35
 
25
36
  private static synchronized void initializeIfNeeded(Context context) {
26
37
  if (!isInitialized) {
@@ -120,6 +131,11 @@ public class DownloadWorkerManager {
120
131
  .addTag(id)
121
132
  .addTag(version)
122
133
  .addTag("capacitor_updater_download");
134
+ // Android 12+ expedited jobs skip the WorkManager delay without a
135
+ // foreground service. Older APIs require getForegroundInfo().
136
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
137
+ workRequestBuilder.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST);
138
+ }
123
139
 
124
140
  // More aggressive retry policy for emulators
125
141
  if (isEmulator) {
@@ -142,17 +158,101 @@ public class DownloadWorkerManager {
142
158
 
143
159
  public static void cancelVersionDownload(Context context, String version) {
144
160
  initializeIfNeeded(context.getApplicationContext());
145
- WorkManager.getInstance(context).cancelAllWorkByTag(version);
161
+ cancelExecutor.execute(() -> cancelVersionDownloadInternal(context, version, false));
162
+ }
163
+
164
+ public static boolean cancelVersionDownloadAndAwait(Context context, String version) {
165
+ initializeIfNeeded(context.getApplicationContext());
166
+ Future<?> future = cancelExecutor.submit(() -> cancelVersionDownloadInternal(context, version, true));
167
+ try {
168
+ future.get(10, TimeUnit.SECONDS);
169
+ return true;
170
+ } catch (TimeoutException e) {
171
+ future.cancel(true);
172
+ logger.error("Timed out awaiting version download cancel");
173
+ return false;
174
+ } catch (Exception e) {
175
+ logger.error("Error awaiting version download cancel: " + e.getMessage());
176
+ return false;
177
+ }
178
+ }
179
+
180
+ private static Set<String> collectManifestIdsForVersion(WorkManager workManager, String version) {
181
+ Set<String> downloadIds = new HashSet<>();
182
+ try {
183
+ List<WorkInfo> workInfos = workManager.getWorkInfosByTag(version).get();
184
+ for (WorkInfo workInfo : workInfos) {
185
+ for (String tag : workInfo.getTags()) {
186
+ if (!"capacitor_updater_download".equals(tag) && !version.equals(tag)) {
187
+ downloadIds.add(tag);
188
+ }
189
+ }
190
+ }
191
+ } catch (InterruptedException e) {
192
+ Thread.currentThread().interrupt();
193
+ throw new IllegalStateException("Interrupted while collecting manifest ids before version cancel", e);
194
+ } catch (Exception e) {
195
+ logger.error("Error collecting manifest ids before version cancel: " + e.getMessage());
196
+ }
197
+ return downloadIds;
198
+ }
199
+
200
+ private static void clearManifestIds(Set<String> downloadIds) {
201
+ for (String downloadId : downloadIds) {
202
+ DataManager.getInstance().clearManifest(downloadId);
203
+ }
204
+ }
205
+
206
+ private static void cancelVersionDownloadInternal(Context context, String version, boolean awaitFinished) {
207
+ if (Thread.currentThread().isInterrupted()) {
208
+ return;
209
+ }
210
+ WorkManager workManager = WorkManager.getInstance(context);
211
+ Set<String> downloadIds = collectManifestIdsForVersion(workManager, version);
212
+ workManager.cancelAllWorkByTag(version);
213
+ clearManifestIds(downloadIds);
214
+ if (awaitFinished) {
215
+ awaitVersionWorkFinished(workManager, version);
216
+ }
217
+ }
218
+
219
+ private static void awaitVersionWorkFinished(WorkManager workManager, String version) {
220
+ for (int i = 0; i < 100; i++) {
221
+ if (Thread.currentThread().isInterrupted()) {
222
+ throw new IllegalStateException("Interrupted while waiting for version download cancel");
223
+ }
224
+ try {
225
+ boolean anyActive = workManager
226
+ .getWorkInfosByTag(version)
227
+ .get()
228
+ .stream()
229
+ .anyMatch((workInfo) -> !workInfo.getState().isFinished());
230
+ if (!anyActive) {
231
+ return;
232
+ }
233
+ Thread.sleep(100);
234
+ } catch (InterruptedException e) {
235
+ Thread.currentThread().interrupt();
236
+ throw new IllegalStateException("Interrupted while waiting for download cancel", e);
237
+ } catch (Exception e) {
238
+ throw new IllegalStateException("Error waiting for download cancel: " + e.getMessage(), e);
239
+ }
240
+ }
241
+ throw new IllegalStateException("Timed out waiting for version download cancel: " + version);
146
242
  }
147
243
 
148
244
  public static void cancelBundleDownload(Context context, String id, String version) {
149
245
  String uniqueWorkName = "bundle_" + id + "_" + version;
150
246
  initializeIfNeeded(context.getApplicationContext());
151
- WorkManager.getInstance(context).cancelUniqueWork(uniqueWorkName);
247
+ WorkManager workManager = WorkManager.getInstance(context);
248
+ workManager.cancelUniqueWork(uniqueWorkName);
249
+ DataManager.getInstance().clearManifest(id);
152
250
  }
153
251
 
154
252
  public static void cancelAllDownloads(Context context) {
155
253
  initializeIfNeeded(context.getApplicationContext());
156
- WorkManager.getInstance(context).cancelAllWorkByTag("capacitor_updater_download");
254
+ WorkManager workManager = WorkManager.getInstance(context);
255
+ workManager.cancelAllWorkByTag("capacitor_updater_download");
256
+ DataManager.getInstance().clearAllManifests();
157
257
  }
158
258
  }
@@ -22,7 +22,7 @@ public class InternalUtils {
22
22
  public static String getPackageName(PackageManager pm, String packageName) {
23
23
  try {
24
24
  PackageInfo pInfo = getPackageInfoInternal(pm, packageName);
25
- return (pInfo != null) ? pInfo.packageName : null;
25
+ return pInfo != null ? pInfo.packageName : null;
26
26
  } catch (PackageManager.NameNotFoundException e) {
27
27
  // Exception is handled internally, and null is returned to indicate the package name could not be retrieved
28
28
  return null;
@@ -13,6 +13,7 @@ import android.hardware.SensorManager;
13
13
  import android.text.Editable;
14
14
  import android.text.TextWatcher;
15
15
  import android.widget.ArrayAdapter;
16
+ import android.widget.Button;
16
17
  import android.widget.EditText;
17
18
  import android.widget.LinearLayout;
18
19
  import android.widget.ListView;
@@ -117,65 +118,8 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
117
118
  isShowing = false;
118
119
  return;
119
120
  }
120
- if (Boolean.TRUE.equals(plugin.shakeChannelSelectorEnabled)) {
121
- showCombinedPreviewMenu();
122
- return;
123
- }
124
- String appName = activity.getPackageManager().getApplicationLabel(activity.getApplicationInfo()).toString();
125
- String title = "Preview " + appName + " Menu";
126
- String message = "Reload, switch, or leave the current preview.";
127
- List<String> actions = new ArrayList<>();
128
- actions.add("Reload preview");
129
- if (plugin.previewMenuPreviews().length() > 0) {
130
- actions.add("Switch preview");
131
- }
132
- actions.add("Leave test app");
133
- final boolean[] openingNestedSelector = { false };
134
- final boolean[] previewActionRunning = { false };
135
121
 
136
- AlertDialog.Builder builder = new AlertDialog.Builder(activity);
137
- builder.setTitle(title);
138
- builder.setMessage(message);
139
- builder.setItems(actions.toArray(new String[0]), (dialogInterface, which) -> {
140
- AlertDialog dialog = (AlertDialog) dialogInterface;
141
- String action = actions.get(which);
142
- if ("Reload preview".equals(action)) {
143
- previewActionRunning[0] = true;
144
- logger.info("Reloading webview");
145
- runPreviewMenuAction(dialog, "Could not reload the test app.", "Error reloading test app: ", () ->
146
- plugin.reloadPreviewSessionFromShakeMenu()
147
- );
148
- } else if ("Switch preview".equals(action)) {
149
- openingNestedSelector[0] = true;
150
- dialog.dismiss();
151
- showPreviewSelector();
152
- } else {
153
- previewActionRunning[0] = true;
154
- runPreviewMenuAction(dialog, "Could not leave the test app.", "Error leaving test app: ", () ->
155
- plugin.leavePreviewSessionFromShakeMenu()
156
- );
157
- }
158
- });
159
-
160
- // Cancel button
161
- builder.setNegativeButton(
162
- "Close menu",
163
- new DialogInterface.OnClickListener() {
164
- public void onClick(DialogInterface dialog, int id) {
165
- logger.info("Shake menu cancelled");
166
- dialog.dismiss();
167
- isShowing = false;
168
- }
169
- }
170
- );
171
-
172
- AlertDialog dialog = builder.create();
173
- dialog.setOnDismissListener((dialogInterface) -> {
174
- if (!openingNestedSelector[0] && !previewActionRunning[0]) {
175
- isShowing = false;
176
- }
177
- });
178
- dialog.show();
122
+ showPreviewActionsMenu(Boolean.TRUE.equals(plugin.shakeChannelSelectorEnabled));
179
123
  } catch (Exception e) {
180
124
  logger.error("Error showing shake menu: " + e.getMessage());
181
125
  isShowing = false;
@@ -183,65 +127,95 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
183
127
  });
184
128
  }
185
129
 
186
- private void showCombinedPreviewMenu() {
187
- try {
188
- String appName = activity.getPackageManager().getApplicationLabel(activity.getApplicationInfo()).toString();
189
- String title = "Preview " + appName + " Menu";
190
- String message = "Reload, switch, or leave the current preview.";
191
- List<String> actions = new ArrayList<>();
192
- actions.add("Reload preview");
193
- if (plugin.previewMenuPreviews().length() > 0) {
194
- actions.add("Switch preview");
195
- }
196
- actions.add("Leave test app");
197
- actions.add("Switch channel");
198
- final boolean[] openingNestedSelector = { false };
199
- final boolean[] previewActionRunning = { false };
130
+ private void showPreviewActionsMenu(boolean includeChannelSelector) {
131
+ String appName = activity.getPackageManager().getApplicationLabel(activity.getApplicationInfo()).toString();
132
+ String title = "Preview " + appName + " Menu";
133
+ String message = "Reload, switch, or leave the current preview.";
134
+ final boolean[] openingNestedSelector = { false };
135
+ final boolean[] previewActionRunning = { false };
136
+ final AlertDialog[] dialogRef = { null };
137
+ List<Button> buttons = new ArrayList<>();
138
+
139
+ LinearLayout layout = new LinearLayout(activity);
140
+ layout.setOrientation(LinearLayout.VERTICAL);
141
+ int horizontalPadding = dpToPx(16);
142
+ int verticalPadding = dpToPx(8);
143
+ layout.setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
144
+
145
+ addPreviewMenuButton(layout, buttons, "Reload preview", () -> {
146
+ AlertDialog dialog = dialogRef[0];
147
+ previewActionRunning[0] = true;
148
+ setPreviewMenuButtonsEnabled(buttons, false);
149
+ logger.info("Reloading webview");
150
+ runPreviewMenuAction(dialog, "Could not reload the test app.", "Error reloading test app: ", () ->
151
+ plugin.reloadPreviewSessionFromShakeMenu()
152
+ );
153
+ });
200
154
 
201
- AlertDialog.Builder builder = new AlertDialog.Builder(activity);
202
- builder.setTitle(title);
203
- builder.setMessage(message);
204
- builder.setItems(actions.toArray(new String[0]), (dialogInterface, which) -> {
205
- AlertDialog dialog = (AlertDialog) dialogInterface;
206
- String action = actions.get(which);
207
- if ("Reload preview".equals(action)) {
208
- previewActionRunning[0] = true;
209
- logger.info("Reloading webview");
210
- runPreviewMenuAction(dialog, "Could not reload the test app.", "Error reloading test app: ", () ->
211
- plugin.reloadPreviewSessionFromShakeMenu()
212
- );
213
- } else if ("Leave test app".equals(action)) {
214
- previewActionRunning[0] = true;
215
- runPreviewMenuAction(dialog, "Could not leave the test app.", "Error leaving test app: ", () ->
216
- plugin.leavePreviewSessionFromShakeMenu()
217
- );
218
- } else if ("Switch preview".equals(action)) {
219
- openingNestedSelector[0] = true;
220
- dialog.dismiss();
221
- showPreviewSelector();
222
- } else {
223
- openingNestedSelector[0] = true;
224
- dialog.dismiss();
225
- showChannelSelector();
226
- }
227
- });
228
- builder.setNegativeButton("Close menu", (dialog, id) -> {
229
- logger.info("Shake menu cancelled");
155
+ if (plugin.previewMenuPreviews().length() > 0) {
156
+ addPreviewMenuButton(layout, buttons, "Switch preview", () -> {
157
+ AlertDialog dialog = dialogRef[0];
158
+ openingNestedSelector[0] = true;
230
159
  dialog.dismiss();
231
- isShowing = false;
160
+ showPreviewSelector();
232
161
  });
162
+ }
233
163
 
234
- AlertDialog dialog = builder.create();
235
- dialog.setOnDismissListener((dialogInterface) -> {
236
- if (!openingNestedSelector[0] && !previewActionRunning[0]) {
237
- isShowing = false;
238
- }
164
+ if (includeChannelSelector) {
165
+ addPreviewMenuButton(layout, buttons, "Switch channel", () -> {
166
+ AlertDialog dialog = dialogRef[0];
167
+ openingNestedSelector[0] = true;
168
+ dialog.dismiss();
169
+ showChannelSelector();
239
170
  });
240
- dialog.show();
241
- } catch (Exception e) {
242
- logger.error("Error showing combined shake menu: " + e.getMessage());
243
- isShowing = false;
244
171
  }
172
+
173
+ addPreviewMenuButton(layout, buttons, "Leave test app", () -> {
174
+ AlertDialog dialog = dialogRef[0];
175
+ previewActionRunning[0] = true;
176
+ setPreviewMenuButtonsEnabled(buttons, false);
177
+ runPreviewMenuAction(dialog, "Could not leave the test app.", "Error leaving test app: ", () ->
178
+ plugin.leavePreviewSessionFromShakeMenu()
179
+ );
180
+ });
181
+
182
+ addPreviewMenuButton(layout, buttons, "Close menu", () -> {
183
+ AlertDialog dialog = dialogRef[0];
184
+ if (dialog != null) {
185
+ logger.info("Shake menu cancelled");
186
+ dialog.dismiss();
187
+ isShowing = false;
188
+ }
189
+ });
190
+
191
+ AlertDialog.Builder builder = new AlertDialog.Builder(activity);
192
+ builder.setTitle(title);
193
+ builder.setMessage(message);
194
+ builder.setView(layout);
195
+
196
+ AlertDialog dialog = builder.create();
197
+ dialogRef[0] = dialog;
198
+ dialog.setOnDismissListener((dialogInterface) -> {
199
+ if (!openingNestedSelector[0] && !previewActionRunning[0]) {
200
+ isShowing = false;
201
+ }
202
+ });
203
+ dialog.show();
204
+ }
205
+
206
+ private void addPreviewMenuButton(LinearLayout layout, List<Button> buttons, String title, Runnable action) {
207
+ Button button = new Button(activity);
208
+ button.setAllCaps(false);
209
+ button.setText(title);
210
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
211
+ LinearLayout.LayoutParams.MATCH_PARENT,
212
+ LinearLayout.LayoutParams.WRAP_CONTENT
213
+ );
214
+ params.setMargins(0, 0, 0, dpToPx(8));
215
+ button.setLayoutParams(params);
216
+ button.setOnClickListener((view) -> action.run());
217
+ buttons.add(button);
218
+ layout.addView(button);
245
219
  }
246
220
 
247
221
  private void runPreviewMenuAction(AlertDialog dialog, String failureMessage, String errorPrefix, PreviewMenuAction action) {
@@ -262,10 +236,13 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
262
236
  }).start();
263
237
  }
264
238
 
265
- private void setPreviewMenuButtonsEnabled(AlertDialog dialog, boolean enabled) {
266
- dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(enabled);
267
- dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setEnabled(enabled);
268
- dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setEnabled(enabled);
239
+ private void setPreviewMenuButtonsEnabled(List<Button> buttons, boolean enabled) {
240
+ for (Button button : buttons) {
241
+ if (!enabled && "Close menu".equals(button.getText().toString())) {
242
+ continue;
243
+ }
244
+ button.setEnabled(enabled);
245
+ }
269
246
  }
270
247
 
271
248
  private void showPreviewSelector() {
@@ -299,7 +276,7 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
299
276
  String name = preview.optString("name", "");
300
277
  JSObject bundle = preview.getJSObject("bundle");
301
278
  String version = bundle == null ? "" : bundle.optString("version", "");
302
- String label = !name.isEmpty() ? name : (!version.isEmpty() ? version : preview.optString("id", "Preview"));
279
+ String label = !name.isEmpty() ? name : !version.isEmpty() ? version : preview.optString("id", "Preview");
303
280
  if (preview.optBoolean("isActive", false)) {
304
281
  label += " (current)";
305
282
  }
@@ -819,8 +796,20 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
819
796
 
820
797
  String latestUrl = getString(latestRes, "url");
821
798
 
822
- // Check if there's an actual update available
823
- if ("up_to_date".equals(latestKind) || latestUrl == null || latestUrl.isEmpty()) {
799
+ Object manifestObj = latestRes.get("manifest");
800
+ JSONArray manifestArray = null;
801
+ if (manifestObj instanceof JSONArray) {
802
+ manifestArray = (JSONArray) manifestObj;
803
+ } else if (manifestObj instanceof List) {
804
+ manifestArray = new JSONArray((List<?>) manifestObj);
805
+ }
806
+ final boolean hasManifest = manifestArray != null && manifestArray.length() > 0;
807
+
808
+ // Check if there's an actual update available. A manifest-only
809
+ // response legitimately has no URL (the files come from the
810
+ // manifest, not a zip), so only report "already on latest" when
811
+ // the URL is empty AND there is no manifest to download from.
812
+ if ("up_to_date".equals(latestKind) || ((latestUrl == null || latestUrl.isEmpty()) && !hasManifest)) {
824
813
  activity.runOnUiThread(() -> {
825
814
  progressDialog.dismiss();
826
815
  showSuccess("Channel set to " + channelName + ". Already on latest version.");
@@ -843,25 +832,18 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
843
832
 
844
833
  String sessionKey = getString(latestRes, "sessionKey");
845
834
  String checksum = getString(latestRes, "checksum");
846
- Object manifestObj = latestRes.get("manifest");
835
+
836
+ // A manifest-only response has no zip URL; downloadManifest
837
+ // tolerates the placeholder URL the plugin already uses.
838
+ final String downloadUrl =
839
+ latestUrl == null || latestUrl.isEmpty() ? "https://404.capgo.app/no.zip" : latestUrl;
847
840
 
848
841
  // Download the update
849
842
  try {
850
843
  BundleInfo bundle;
851
- if (manifestObj != null) {
852
- JSONArray manifestArray = null;
853
- if (manifestObj instanceof JSONArray) {
854
- manifestArray = (JSONArray) manifestObj;
855
- } else if (manifestObj instanceof List) {
856
- manifestArray = new JSONArray((List<?>) manifestObj);
857
- }
858
-
859
- if (manifestArray == null) {
860
- throw new IllegalArgumentException("Invalid manifest format");
861
- }
862
-
844
+ if (hasManifest) {
863
845
  bundle = updater.downloadManifest(
864
- latestUrl,
846
+ downloadUrl,
865
847
  versionForUi,
866
848
  sessionKey != null ? sessionKey : "",
867
849
  checksum != null ? checksum : "",
@@ -869,7 +851,7 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
869
851
  );
870
852
  } else {
871
853
  bundle = updater.download(
872
- latestUrl,
854
+ downloadUrl,
873
855
  versionForUi,
874
856
  sessionKey != null ? sessionKey : "",
875
857
  checksum != null ? checksum : ""
package/dist/docs.json CHANGED
@@ -156,7 +156,7 @@
156
156
  "text": "{Error} If the download fails or the bundle is invalid."
157
157
  }
158
158
  ],
159
- "docs": "Download a new bundle from the provided URL for later installation.\n\nThe downloaded bundle is stored locally but not activated. To use it:\n- Call {@link next} to set it for installation on next app backgrounding/restart\n- Call {@link set} to activate it immediately (destroys current JavaScript context)\n\nThe URL should point to a zip file containing either:\n- Your app files directly in the zip root, or\n- A single folder containing all your app files\n\nThe bundle must include an `index.html` file at the root level.\n\nFor encrypted bundles, provide the `sessionKey` and `checksum` parameters.\nFor multi-file delta updates, provide the `manifest` array.\n\n**Android Background Runner note:** `@capacitor/background-runner` loads its\nconfigured runner script from native APK assets. Live updates cannot replace\nthat runner script. Keep it stable across OTA updates and ship a native app\nupdate when the runner code changes.",
159
+ "docs": "Download a new bundle from the provided URL for later installation.\n\nThe downloaded bundle is stored locally but not activated. To use it:\n- Call {@link next} to set it for installation on next app backgrounding/restart\n- Call {@link set} to activate it immediately (destroys current JavaScript context)\n\nThe URL should point to a zip file containing either:\n- Your app files directly in the zip root, or\n- A single folder containing all your app files\n\nThe bundle must include an `index.html` file at the root level.\n\nFor encrypted bundles, provide the `sessionKey` and `checksum` parameters.\nFor multi-file delta updates, provide the `manifest` array.\n\n**Android Background Runner note:** `@capacitor/background-runner` loads its\nconfigured runner script from native APK assets. Live updates cannot replace\nthat runner script. Keep it stable across OTA updates and ship a native app\nupdate when the runner code changes. When a bundle switch happens, Capacitor\nUpdater cancels and reschedules configured Background Runner WorkManager jobs\nand syncs the bundled runner script into native `public/` storage when present.",
160
160
  "complexTypes": [
161
161
  "BundleInfo",
162
162
  "DownloadOptions"
@@ -846,7 +846,7 @@
846
846
  "text": "4.7.0"
847
847
  }
848
848
  ],
849
- "docs": "Assign this device to a specific update channel at runtime.\n\nChannels allow you to distribute different bundle versions to different groups of users\n(e.g., \"production\", \"beta\", \"staging\"). This method switches the device to a new channel.\n\n**Device Override UI:** `setChannel()` validates the channel with the backend, then stores the\nselected channel locally on the device. It does not create or update a backend Device Override,\nso the device will not appear as overridden in the Capgo dashboard. Only assignments created\nfrom the dashboard or the Public API are shown in the Device Override UI.\n\n**Requirements:**\n- The target channel must allow self-assignment (configured in your Capgo dashboard or backend)\n- The backend may accept or reject the request based on channel settings\n\n**When to use:**\n- After the app is ready and the user has interacted (e.g., opted into beta program)\n- To implement in-app channel switching (beta toggle, tester access, etc.)\n- For user-driven channel changes\n\n**When NOT to use:**\n- At app boot/initialization - use {@link PluginsConfig.CapacitorUpdater.defaultChannel} config instead\n- Before user interaction\n\n**Important: Listen for the `channelPrivate` event**\n\nWhen a user attempts to set a channel that doesn't allow device self-assignment, the method will\nthrow an error AND fire a {@link addListener}('channelPrivate') event. You should listen to this event\nto provide appropriate feedback to users:\n\n```typescript\nCapacitorUpdater.addListener('channelPrivate', (data) => {\n console.warn(`Cannot access channel \"${data.channel}\": ${data.message}`);\n // Show user-friendly message\n});\n```\n\nThis sends a request to the Capgo backend to validate the specified channel, then stores the\nchannel locally on the device.",
849
+ "docs": "Assign this device to a specific update channel at runtime.\n\nChannels allow you to distribute different bundle versions to different groups of users\n(e.g., \"production\", \"beta\", \"staging\"). This method switches the device to a new channel.\n\n**Device Override UI:** `setChannel()` validates the channel with the backend, then stores the\nselected channel locally on the device for future app restarts. It does not create or update\na backend Device Override, so the device will not appear as overridden in the Capgo dashboard.\nOnly assignments created from the dashboard or the Public API are shown in the Device Override UI.\n\n**Requirements:**\n- The target channel must allow self-assignment (configured in your Capgo dashboard or backend)\n- The backend may accept or reject the request based on channel settings\n\n**When to use:**\n- After the app is ready and the user has interacted (e.g., opted into beta program)\n- To implement in-app channel switching (beta toggle, tester access, etc.)\n- For user-driven channel changes\n\n**When NOT to use:**\n- At app boot/initialization - use {@link PluginsConfig.CapacitorUpdater.defaultChannel} config instead\n- Before user interaction\n\n**Important: Listen for the `channelPrivate` event**\n\nWhen a user attempts to set a channel that doesn't allow device self-assignment, the method will\nthrow an error AND fire a {@link addListener}('channelPrivate') event. You should listen to this event\nto provide appropriate feedback to users:\n\n```typescript\nCapacitorUpdater.addListener('channelPrivate', (data) => {\n console.warn(`Cannot access channel \"${data.channel}\": ${data.message}`);\n // Show user-friendly message\n});\n```\n\nThis sends a request to the Capgo backend to validate the specified channel, then stores the\nchannel locally on the device for future app restarts.",
850
850
  "complexTypes": [
851
851
  "ChannelRes",
852
852
  "SetChannelOptions"
@@ -911,7 +911,7 @@
911
911
  "text": "4.8.0"
912
912
  }
913
913
  ],
914
- "docs": "Get the current channel assigned to this device.\n\nReturns information about:\n- `channel`: The currently assigned channel name (if any)\n- `allowSet`: Whether the channel allows self-assignment\n- `status`: Operation status\n- `error`/`message`: Additional information (if applicable)\n\nUse this to:\n- Display current channel to users (e.g., \"You're on the Beta channel\")\n- Check if a device is on a specific channel before showing features\n- Verify channel assignment after calling {@link setChannel}\n\nOn native platforms, a successful response also refreshes the locally persisted\ndefault channel used by update checks.",
914
+ "docs": "Get the current channel assigned to this device.\n\nReturns information about:\n- `channel`: The currently assigned channel name (if any)\n- `allowSet`: Whether the channel allows self-assignment\n- `status`: Operation status\n- `error`/`message`: Additional information (if applicable)\n\nUse this to:\n- Display current channel to users (e.g., \"You're on the Beta channel\")\n- Check if a device is on a specific channel before showing features\n- Verify channel assignment after calling {@link setChannel}\n\nOn native platforms, a successful response also refreshes the default channel used by update checks.\nThis refresh is persisted across app restarts.",
915
915
  "complexTypes": [
916
916
  "GetChannelRes"
917
917
  ],
@@ -4468,6 +4468,14 @@
4468
4468
  {
4469
4469
  "text": "'downloading'",
4470
4470
  "complexTypes": []
4471
+ },
4472
+ {
4473
+ "text": "'deleted'",
4474
+ "complexTypes": []
4475
+ },
4476
+ {
4477
+ "text": "'deleting'",
4478
+ "complexTypes": []
4471
4479
  }
4472
4480
  ]
4473
4481
  },
@@ -4575,7 +4583,7 @@
4575
4583
  "name": "example"
4576
4584
  }
4577
4585
  ],
4578
- "docs": "Configure the number of seconds the native plugin should wait before considering API timeout.\n\nOnly available for Android and iOS.",
4586
+ "docs": "Configure the number of seconds the native plugin should wait before considering an HTTP timeout.\nApplies to update checks and file downloads. On Android these are idle connect/read/write\ntimeouts and do not cap total download time; on iOS the request timeout also bounds the\ntotal download duration.\n\nOnly available for Android and iOS.",
4579
4587
  "complexTypes": [],
4580
4588
  "type": "number | undefined"
4581
4589
  },
@@ -4623,7 +4631,7 @@
4623
4631
  "name": "example"
4624
4632
  }
4625
4633
  ],
4626
- "docs": "Configure how the plugin checks for, downloads, and applies live updates.\n\nThe plugin checks for updates when the app moves to the foreground and, if\n{@link periodCheckDelay} is set, on a repeating timer while the app stays open.\n\nBoolean values keep their existing behavior:\n- `true`: Same as `\"atBackground\"`.\n- `false`: Same as `\"off\"`.\n\nString values merge the previous Auto Update and Direct Update configuration:\n- `\"off\"`: Disable automatic update checks.\n- `\"atBackground\"`: Check and download automatically on each foreground check, then apply the update the next time the app moves to background.\n- `\"atInstall\"`: Apply immediately only after a fresh install or native app store update; otherwise use `\"atBackground\"` behavior.\n- `\"onLaunch\"`: Apply immediately only when the app is brought to the foreground from a killed state (cold start). After that first check, fall back to `\"atBackground\"` behavior.\n- `\"always\"`: Check on every foreground transition and apply immediately whenever an update is available.\n- `\"onlyDownload\"`: Check and download automatically, emit `updateAvailable`, and never set the next bundle or apply an update automatically.\n\nOnly available for Android and iOS.",
4634
+ "docs": "Configure how the plugin checks for, downloads, and applies live updates.\n\nThe plugin checks for updates when the app moves to the foreground. When\n{@link periodCheckDelay} is greater than 0, it also checks on a repeating timer\nwhile the app stays open.\n\nBoolean values keep their existing behavior:\n- `true`: Same as `\"atBackground\"`.\n- `false`: Same as `\"off\"`.\n\nString values merge the previous Auto Update and Direct Update configuration:\n- `\"off\"`: Disable automatic update checks.\n- `\"atBackground\"`: Check and download automatically on each foreground check, then apply the update the next time the app moves to background.\n- `\"atInstall\"`: Apply immediately only after a fresh install or native app store update; otherwise use `\"atBackground\"` behavior.\n- `\"onLaunch\"`: Apply immediately only when the app is brought to the foreground from a killed state (cold start). After that first check, fall back to `\"atBackground\"` behavior.\n- `\"always\"`: Check on every foreground transition and apply immediately whenever an update is available.\n- `\"onlyDownload\"`: Check and download automatically, emit `updateAvailable`, and never set the next bundle or apply an update automatically.\n\nOnly available for Android and iOS.",
4627
4635
  "complexTypes": [],
4628
4636
  "type": "boolean | 'always' | 'off' | 'atBackground' | 'atInstall' | 'onLaunch' | 'onlyDownload' | undefined"
4629
4637
  },
@@ -4687,7 +4695,7 @@
4687
4695
  "name": "example"
4688
4696
  }
4689
4697
  ],
4690
- "docs": "Configure the URL / endpoint to which update statistics are sent.\n\nOnly available for Android and iOS. Set to \"\" to disable stats reporting.\nNative stats include update lifecycle events, app health signals such as crashes,\nAndroid ANRs, low-memory exits, iOS memory warnings, and WebView health signals\nsuch as JavaScript errors, unhandled promise rejections, resource load failures,\nWebView renderer exits, and unclean WebView restarts when available.",
4698
+ "docs": "Configure the URL / endpoint to which update statistics are sent.\n\nOnly available for Android and iOS. Set to \"\" to disable stats reporting.\nNative stats include update lifecycle events, app health signals such as crashes,\nAndroid ANRs, low-memory exits, iOS memory warnings, and WebView health signals\nsuch as JavaScript errors, unhandled promise rejections, resource load failures,\nWebView renderer exits, unclean WebView restarts, app launch readiness timing,\nand WebView load milestones when available.",
4691
4699
  "complexTypes": [],
4692
4700
  "type": "string | undefined"
4693
4701
  },
@@ -4727,7 +4735,7 @@
4727
4735
  "name": "directUpdate",
4728
4736
  "tags": [
4729
4737
  {
4730
- "text": "Use {@link PluginsConfig.CapacitorUpdater.autoUpdate} string modes instead.\nWorks well for apps less than 10MB and with uploads done using --delta flag.\nZip or apps more than 10MB will be relatively slow for users to update.\n- false: Never do direct updates (use default behavior: download on foreground check, apply when backgrounded)\n- atInstall: Direct update only after app install or native app store update, otherwise act as directUpdate = false\n- onLaunch: Direct update only when the app is brought to the foreground from a killed state, otherwise act as directUpdate = false\n- always: Direct update on every foreground check whenever an update is available, never act as directUpdate = false\n- true: (deprecated) Same as \"always\" for backward compatibility\n\nActivate this flag will automatically make the CLI upload delta in CICD envs and will ask for confirmation in local uploads.\nOnly available for Android and iOS.",
4738
+ "text": "Use {@link PluginsConfig.CapacitorUpdater.autoUpdate} string modes instead.\nWorks well for apps less than 10MB and with uploads done using --delta flag.\nZip or apps more than 10MB will be relatively slow for users to update.\n- false: Never do direct updates\n- atInstall: Same as `\"atInstall\"` for {@link autoUpdate}\n- onLaunch: Same as `\"onLaunch\"` for {@link autoUpdate}\n- always: Same as `\"always\"` for {@link autoUpdate}\n- true: (deprecated) Same as \"always\" for backward compatibility\n\nActivate this flag will automatically make the CLI upload delta in CICD envs and will ask for confirmation in local uploads.\nOnly available for Android and iOS.",
4731
4739
  "name": "deprecated"
4732
4740
  },
4733
4741
  {
@@ -4807,7 +4815,7 @@
4807
4815
  "name": "example"
4808
4816
  }
4809
4817
  ],
4810
- "docs": "Configure the delay period for period update check. the unit is in seconds.\n\nOnly available for Android and iOS.\nCannot be less than 600 seconds (10 minutes).",
4818
+ "docs": "Configure the interval in seconds for repeating update checks while the app stays open.\nForeground checks still run when this is 0. Values below 600 are normalized to 600.\n\nOnly available for Android and iOS.\nCannot be less than 600 seconds (10 minutes).",
4811
4819
  "complexTypes": [],
4812
4820
  "type": "number | undefined"
4813
4821
  },
@@ -5035,6 +5043,22 @@
5035
5043
  "complexTypes": [],
5036
5044
  "type": "boolean | undefined"
5037
5045
  },
5046
+ {
5047
+ "name": "persistDefaultChannelOnReinstall",
5048
+ "tags": [
5049
+ {
5050
+ "text": "true",
5051
+ "name": "default"
5052
+ },
5053
+ {
5054
+ "text": "8.51.0",
5055
+ "name": "since"
5056
+ }
5057
+ ],
5058
+ "docs": "Keep the default channel stored by {@link CapacitorUpdaterPlugin.setChannel} or refreshed by\n{@link CapacitorUpdaterPlugin.getChannel} when app data is restored into a new app install.\n\n`setChannel()` and a successful `getChannel()` still persist the selected channel across app\nrestarts. When this option is `false`, native startup clears that persisted channel when it\ndetects app data restored into a new installation. Native build cleanup clears the persisted\nchannel only when `persistDefaultChannelOnReinstall` is `false`, `resetWhenUpdate` is `true`,\nand the native build version has changed.\n\nOnly available for Android and iOS.",
5059
+ "complexTypes": [],
5060
+ "type": "boolean | undefined"
5061
+ },
5038
5062
  {
5039
5063
  "name": "defaultChannel",
5040
5064
  "tags": [