@capgo/capacitor-updater 5.50.2 → 5.51.15

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 (28) hide show
  1. package/CapgoCapacitorUpdater.podspec +1 -1
  2. package/Package.swift +3 -2
  3. package/README.md +53 -48
  4. package/android/build.gradle +1 -0
  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 +452 -139
  9. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1354 -412
  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/DownloadService.java +540 -224
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +103 -3
  15. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
  16. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +131 -145
  17. package/dist/docs.json +32 -8
  18. package/dist/esm/definitions.d.ts +41 -17
  19. package/dist/esm/definitions.js.map +1 -1
  20. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +124 -0
  21. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +9 -1
  22. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
  23. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +787 -92
  24. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1014 -268
  25. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
  26. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
  27. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
  28. package/package.json +12 -7
@@ -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) {
@@ -259,13 +233,17 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
259
233
  isShowing = false;
260
234
  });
261
235
  }
262
- }).start();
236
+ })
237
+ .start();
263
238
  }
264
239
 
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);
240
+ private void setPreviewMenuButtonsEnabled(List<Button> buttons, boolean enabled) {
241
+ for (Button button : buttons) {
242
+ if (!enabled && "Close menu".equals(button.getText().toString())) {
243
+ continue;
244
+ }
245
+ button.setEnabled(enabled);
246
+ }
269
247
  }
270
248
 
271
249
  private void showPreviewSelector() {
@@ -299,7 +277,7 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
299
277
  String name = preview.optString("name", "");
300
278
  JSObject bundle = preview.getJSObject("bundle");
301
279
  String version = bundle == null ? "" : bundle.optString("version", "");
302
- String label = !name.isEmpty() ? name : (!version.isEmpty() ? version : preview.optString("id", "Preview"));
280
+ String label = !name.isEmpty() ? name : !version.isEmpty() ? version : preview.optString("id", "Preview");
303
281
  if (preview.optBoolean("isActive", false)) {
304
282
  label += " (current)";
305
283
  }
@@ -405,7 +383,8 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
405
383
  } finally {
406
384
  isShowing = false;
407
385
  }
408
- }).start();
386
+ })
387
+ .start();
409
388
  }
410
389
 
411
390
  private void showConfiguredDefaultMenu() {
@@ -592,7 +571,8 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
592
571
  presentChannelPicker(channels);
593
572
  });
594
573
  });
595
- }).start();
574
+ })
575
+ .start();
596
576
  } catch (Exception e) {
597
577
  logger.error("Error showing channel selector: " + e.getMessage());
598
578
  isShowing = false;
@@ -785,14 +765,13 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
785
765
  String latestKind = getString(latestRes, "kind");
786
766
  String latestMessage = getString(latestRes, "message");
787
767
 
788
- String detail =
789
- latestMessage != null && !latestMessage.isEmpty()
790
- ? latestMessage
791
- : latestError != null && !latestError.isEmpty()
792
- ? latestError
793
- : latestKind != null && !latestKind.isEmpty()
794
- ? latestKind
795
- : "server did not provide a message";
768
+ String detail = latestMessage != null && !latestMessage.isEmpty()
769
+ ? latestMessage
770
+ : latestError != null && !latestError.isEmpty()
771
+ ? latestError
772
+ : latestKind != null && !latestKind.isEmpty()
773
+ ? latestKind
774
+ : "server did not provide a message";
796
775
 
797
776
  // Handle update errors first (before "no new version" check)
798
777
  if (
@@ -819,8 +798,20 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
819
798
 
820
799
  String latestUrl = getString(latestRes, "url");
821
800
 
822
- // Check if there's an actual update available
823
- if ("up_to_date".equals(latestKind) || latestUrl == null || latestUrl.isEmpty()) {
801
+ Object manifestObj = latestRes.get("manifest");
802
+ JSONArray manifestArray = null;
803
+ if (manifestObj instanceof JSONArray) {
804
+ manifestArray = (JSONArray) manifestObj;
805
+ } else if (manifestObj instanceof List) {
806
+ manifestArray = new JSONArray((List<?>) manifestObj);
807
+ }
808
+ final boolean hasManifest = manifestArray != null && manifestArray.length() > 0;
809
+
810
+ // Check if there's an actual update available. A manifest-only
811
+ // response legitimately has no URL (the files come from the
812
+ // manifest, not a zip), so only report "already on latest" when
813
+ // the URL is empty AND there is no manifest to download from.
814
+ if ("up_to_date".equals(latestKind) || ((latestUrl == null || latestUrl.isEmpty()) && !hasManifest)) {
824
815
  activity.runOnUiThread(() -> {
825
816
  progressDialog.dismiss();
826
817
  showSuccess("Channel set to " + channelName + ". Already on latest version.");
@@ -843,25 +834,19 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
843
834
 
844
835
  String sessionKey = getString(latestRes, "sessionKey");
845
836
  String checksum = getString(latestRes, "checksum");
846
- Object manifestObj = latestRes.get("manifest");
837
+
838
+ // A manifest-only response has no zip URL; downloadManifest
839
+ // tolerates the placeholder URL the plugin already uses.
840
+ final String downloadUrl = latestUrl == null || latestUrl.isEmpty()
841
+ ? "https://404.capgo.app/no.zip"
842
+ : latestUrl;
847
843
 
848
844
  // Download the update
849
845
  try {
850
846
  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
-
847
+ if (hasManifest) {
863
848
  bundle = updater.downloadManifest(
864
- latestUrl,
849
+ downloadUrl,
865
850
  versionForUi,
866
851
  sessionKey != null ? sessionKey : "",
867
852
  checksum != null ? checksum : "",
@@ -869,7 +854,7 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
869
854
  );
870
855
  } else {
871
856
  bundle = updater.download(
872
- latestUrl,
857
+ downloadUrl,
873
858
  versionForUi,
874
859
  sessionKey != null ? sessionKey : "",
875
860
  checksum != null ? checksum : ""
@@ -911,7 +896,8 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
911
896
  });
912
897
  }
913
898
  );
914
- }).start();
899
+ })
900
+ .start();
915
901
  } catch (Exception e) {
916
902
  logger.error("Error selecting channel: " + e.getMessage());
917
903
  isShowing = false;