@capgo/capacitor-updater 8.50.2 → 8.51.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,7 +13,14 @@ import android.content.pm.PackageManager;
13
13
  import android.os.Build;
14
14
  import androidx.annotation.NonNull;
15
15
  import androidx.lifecycle.LifecycleOwner;
16
+ import androidx.work.Constraints;
16
17
  import androidx.work.Data;
18
+ import androidx.work.ExistingPeriodicWorkPolicy;
19
+ import androidx.work.ExistingWorkPolicy;
20
+ import androidx.work.ListenableWorker;
21
+ import androidx.work.NetworkType;
22
+ import androidx.work.OneTimeWorkRequest;
23
+ import androidx.work.PeriodicWorkRequest;
17
24
  import androidx.work.WorkInfo;
18
25
  import androidx.work.WorkManager;
19
26
  import com.google.common.util.concurrent.Futures;
@@ -32,7 +39,9 @@ import java.security.SecureRandom;
32
39
  import java.util.ArrayList;
33
40
  import java.util.Date;
34
41
  import java.util.HashMap;
42
+ import java.util.HashSet;
35
43
  import java.util.Iterator;
44
+ import java.util.LinkedHashSet;
36
45
  import java.util.List;
37
46
  import java.util.Map;
38
47
  import java.util.Objects;
@@ -65,13 +74,20 @@ public class CapgoUpdater {
65
74
  private static final String FALLBACK_VERSION = "pastVersion";
66
75
  private static final String NEXT_VERSION = "nextVersion";
67
76
  private static final String PREVIEW_FALLBACK_VERSION = "previewFallbackVersion";
77
+ private static final String PENDING_DELETE_IDS = "pendingDeleteIds";
68
78
  private static final String bundleDirectory = "versions";
69
79
  private static final String TEMP_UNZIP_PREFIX = "capgo_unzip_";
80
+ private static final long DELETE_PACE_MS = 75L;
81
+ private final Object deleteLock = new Object();
70
82
  private static final String CAPACITOR_CONFIG_ASSET = "capacitor.config.json";
71
83
  private static final String BACKGROUND_RUNNER_CONFIG_KEY = "BackgroundRunner";
84
+ private static final String BACKGROUND_RUNNER_WORKER_CLASS = "io.ionic.backgroundrunner.plugin.RunnerWorker";
72
85
 
73
86
  public static final String TAG = "Capacitor-updater";
74
87
  public SharedPreferences.Editor editor;
88
+
89
+ /** Optional gate run before any download touches disk (e.g. wait for launch cleanup). */
90
+ public Runnable downloadGate = null;
75
91
  public SharedPreferences prefs;
76
92
 
77
93
  public File documentsDir;
@@ -128,7 +144,7 @@ public class CapgoUpdater {
128
144
 
129
145
  private final FilenameFilter filter = (f, name) -> {
130
146
  // ignore directories generated by mac os x
131
- return (!name.startsWith("__MACOSX") && !name.startsWith(".") && !name.startsWith(".DS_Store"));
147
+ return !name.startsWith("__MACOSX") && !name.startsWith(".") && !name.startsWith(".DS_Store");
132
148
  };
133
149
 
134
150
  private boolean isProd() {
@@ -656,6 +672,9 @@ public class CapgoUpdater {
656
672
  if ("low_mem_fail".equals(error)) {
657
673
  sendStats("low_mem_fail", failedVersion);
658
674
  }
675
+ if ("insufficient_disk_space".equals(error)) {
676
+ sendStats("insufficient_disk_space", failedVersion);
677
+ }
659
678
  ret.put("error", error != null ? error : "download_fail");
660
679
  sendStats("download_fail", failedVersion);
661
680
  notifyListeners("downloadFailed", ret);
@@ -916,6 +935,11 @@ public class CapgoUpdater {
916
935
 
917
936
  try {
918
937
  this.deleteDirectory(entry, threadToCheck);
938
+ if (entry.exists()) {
939
+ logger.error("Orphan bundle directory still present after delete");
940
+ logger.debug("Bundle ID: " + id);
941
+ continue;
942
+ }
919
943
  this.removeBundleInfo(id);
920
944
  logger.info("Deleted orphan bundle directory");
921
945
  logger.debug("Bundle ID: " + id);
@@ -927,6 +951,43 @@ public class CapgoUpdater {
927
951
  }
928
952
  }
929
953
 
954
+ public Set<String> allowedBundleIdsForCleanup() {
955
+ final Set<String> allowedIds = new HashSet<>();
956
+ for (final BundleInfo info : this.list(true)) {
957
+ if (info == null || info.getId() == null || info.getId().isEmpty()) {
958
+ continue;
959
+ }
960
+ // DELETED tombstones must not protect leftover folders.
961
+ // DELETING stays protected so drainPendingDeletes owns the removal.
962
+ if (info.isDeleted()) {
963
+ continue;
964
+ }
965
+ allowedIds.add(info.getId());
966
+ }
967
+ final String currentId = this.getCurrentBundleId();
968
+ if (currentId != null && !currentId.isEmpty()) {
969
+ allowedIds.add(currentId);
970
+ }
971
+ final BundleInfo fallback = this.getFallbackBundle();
972
+ if (fallback != null && fallback.getId() != null && !fallback.getId().isEmpty() && !fallback.isDeleting()) {
973
+ allowedIds.add(fallback.getId());
974
+ }
975
+ final BundleInfo next = this.getNextBundle();
976
+ if (next != null && next.getId() != null && !next.getId().isEmpty() && !next.isDeleting()) {
977
+ allowedIds.add(next.getId());
978
+ }
979
+ final BundleInfo previewFallback = this.getPreviewFallbackBundle();
980
+ if (
981
+ previewFallback != null &&
982
+ previewFallback.getId() != null &&
983
+ !previewFallback.getId().isEmpty() &&
984
+ !previewFallback.isDeleting()
985
+ ) {
986
+ allowedIds.add(previewFallback.getId());
987
+ }
988
+ return allowedIds;
989
+ }
990
+
930
991
  public void cleanupOrphanedTempFolders(final Thread threadToCheck) {
931
992
  if (this.documentsDir == null) {
932
993
  logger.warn("Documents directory is null, skipping temp folder cleanup");
@@ -983,7 +1044,7 @@ public class CapgoUpdater {
983
1044
  }
984
1045
 
985
1046
  private void setCurrentBundle(final File bundle) {
986
- this.cancelBackgroundRunnerWorkBeforeBundleSwitch();
1047
+ this.resetBackgroundRunnerWorkForBundleSwitch(bundle);
987
1048
  this.editor.putString(this.CAP_SERVER_PATH, bundle.getPath());
988
1049
  logger.info("Current bundle set to: " + bundle);
989
1050
  this.editor.commit();
@@ -993,7 +1054,33 @@ public class CapgoUpdater {
993
1054
  return bundlePath != null && !bundlePath.trim().isEmpty() && !isBuiltin && !hasStoredBundleInfo;
994
1055
  }
995
1056
 
996
- static String getBackgroundRunnerLabelFromConfig(final String configJson) {
1057
+ static final class BackgroundRunnerWorkConfig {
1058
+
1059
+ final String label;
1060
+ final String src;
1061
+ final String event;
1062
+ final boolean autoStart;
1063
+ final boolean repeat;
1064
+ final int interval;
1065
+
1066
+ BackgroundRunnerWorkConfig(
1067
+ final String label,
1068
+ final String src,
1069
+ final String event,
1070
+ final boolean autoStart,
1071
+ final boolean repeat,
1072
+ final int interval
1073
+ ) {
1074
+ this.label = label;
1075
+ this.src = src;
1076
+ this.event = event;
1077
+ this.autoStart = autoStart;
1078
+ this.repeat = repeat;
1079
+ this.interval = interval;
1080
+ }
1081
+ }
1082
+
1083
+ static BackgroundRunnerWorkConfig getBackgroundRunnerWorkConfigFromConfig(final String configJson) {
997
1084
  if (configJson == null || configJson.trim().isEmpty()) {
998
1085
  return null;
999
1086
  }
@@ -1011,12 +1098,30 @@ public class CapgoUpdater {
1011
1098
  }
1012
1099
 
1013
1100
  final String label = backgroundRunner.optString("label", "").trim();
1014
- return label.isEmpty() ? null : label;
1101
+ if (label.isEmpty()) {
1102
+ return null;
1103
+ }
1104
+
1105
+ final String src = backgroundRunner.optString("src", "").trim();
1106
+ final String event = backgroundRunner.optString("event", "").trim();
1107
+ return new BackgroundRunnerWorkConfig(
1108
+ label,
1109
+ src,
1110
+ event,
1111
+ backgroundRunner.optBoolean("autoStart", false),
1112
+ backgroundRunner.optBoolean("repeat", false),
1113
+ backgroundRunner.optInt("interval", 0)
1114
+ );
1015
1115
  } catch (JSONException ignored) {
1016
1116
  return null;
1017
1117
  }
1018
1118
  }
1019
1119
 
1120
+ static String getBackgroundRunnerLabelFromConfig(final String configJson) {
1121
+ final BackgroundRunnerWorkConfig config = getBackgroundRunnerWorkConfigFromConfig(configJson);
1122
+ return config == null ? null : config.label;
1123
+ }
1124
+
1020
1125
  private String readAssetAsString(final String assetPath) throws IOException {
1021
1126
  final StringBuilder buffer = new StringBuilder();
1022
1127
  try (
@@ -1032,32 +1137,128 @@ public class CapgoUpdater {
1032
1137
  return buffer.toString();
1033
1138
  }
1034
1139
 
1035
- private void cancelBackgroundRunnerWorkBeforeBundleSwitch() {
1140
+ private void copyFileAtomically(final File source, final File dest) throws IOException {
1141
+ final File parent = dest.getParentFile();
1142
+ if (parent != null && !parent.exists() && !parent.mkdirs()) {
1143
+ throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
1144
+ }
1145
+
1146
+ final File tempFile = new File(parent, dest.getName() + ".capgo_tmp");
1147
+ try (final FileInputStream input = new FileInputStream(source); final FileOutputStream output = new FileOutputStream(tempFile)) {
1148
+ final byte[] buffer = new byte[1024 * 1024];
1149
+ int length;
1150
+ while ((length = input.read(buffer)) != -1) {
1151
+ output.write(buffer, 0, length);
1152
+ }
1153
+ }
1154
+
1155
+ if (!tempFile.renameTo(dest)) {
1156
+ if (!dest.delete() || !tempFile.renameTo(dest)) {
1157
+ tempFile.delete();
1158
+ throw new IOException("Failed to replace file: " + dest.getAbsolutePath());
1159
+ }
1160
+ }
1161
+ }
1162
+
1163
+ private void syncBackgroundRunnerScriptFromBundle(final File bundle, final BackgroundRunnerWorkConfig config) {
1164
+ if (this.activity == null || bundle == null || config == null || config.src == null || config.src.isEmpty()) {
1165
+ return;
1166
+ }
1167
+
1168
+ if (bundle.getPath().endsWith("/public") || "public".equals(bundle.getName())) {
1169
+ return;
1170
+ }
1171
+
1172
+ try {
1173
+ final File source = resolvePathInsideDirectory(bundle, config.src);
1174
+ if (!source.isFile()) {
1175
+ return;
1176
+ }
1177
+
1178
+ final File publicDir = new File(this.activity.getFilesDir(), "public");
1179
+ final File dest = resolvePathInsideDirectory(publicDir, config.src);
1180
+ this.copyFileAtomically(source, dest);
1181
+ logger.info("Synced Background Runner script into native public storage before bundle switch.");
1182
+ logger.debug("Background Runner script path: " + dest.getAbsolutePath());
1183
+ } catch (Exception e) {
1184
+ logger.debug("Background Runner script sync skipped: " + e.getMessage());
1185
+ }
1186
+ }
1187
+
1188
+ private void resetBackgroundRunnerWorkForBundleSwitch(final File bundle) {
1036
1189
  if (this.activity == null) {
1037
1190
  return;
1038
1191
  }
1039
1192
 
1040
- final String label;
1193
+ final BackgroundRunnerWorkConfig config;
1041
1194
  try {
1042
- label = getBackgroundRunnerLabelFromConfig(this.readAssetAsString(CAPACITOR_CONFIG_ASSET));
1195
+ config = getBackgroundRunnerWorkConfigFromConfig(this.readAssetAsString(CAPACITOR_CONFIG_ASSET));
1043
1196
  } catch (IOException ignored) {
1044
1197
  return;
1045
1198
  }
1046
1199
 
1047
- if (label == null) {
1200
+ if (config == null) {
1048
1201
  return;
1049
1202
  }
1050
1203
 
1051
1204
  try {
1052
1205
  final WorkManager workManager = WorkManager.getInstance(this.activity.getApplicationContext());
1053
- workManager.cancelUniqueWork(label);
1054
- workManager.cancelAllWorkByTag(label);
1206
+ workManager.cancelUniqueWork(config.label);
1207
+ workManager.cancelAllWorkByTag(config.label);
1055
1208
  logger.info("Cancelled Background Runner work before bundle switch.");
1056
- logger.debug("Background Runner label: " + label);
1209
+ logger.debug("Background Runner label: " + config.label);
1057
1210
  } catch (Exception e) {
1058
1211
  logger.warn("Failed to cancel Background Runner work before bundle switch.");
1059
1212
  logger.debug("Background Runner cancellation error: " + e.getMessage());
1060
1213
  }
1214
+
1215
+ this.syncBackgroundRunnerScriptFromBundle(bundle, config);
1216
+ this.rescheduleBackgroundRunnerWork(config);
1217
+ }
1218
+
1219
+ private void rescheduleBackgroundRunnerWork(final BackgroundRunnerWorkConfig config) {
1220
+ if (!config.autoStart || config.interval <= 0 || config.src.isEmpty()) {
1221
+ return;
1222
+ }
1223
+
1224
+ try {
1225
+ @SuppressWarnings("unchecked")
1226
+ final Class<? extends ListenableWorker> workerClass = (Class<? extends ListenableWorker>) Class.forName(
1227
+ BACKGROUND_RUNNER_WORKER_CLASS
1228
+ );
1229
+ final Data data = new Data.Builder()
1230
+ .putString("label", config.label)
1231
+ .putString("src", config.src)
1232
+ .putString("event", config.event)
1233
+ .build();
1234
+ final Constraints constraints = new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build();
1235
+ final WorkManager workManager = WorkManager.getInstance(this.activity.getApplicationContext());
1236
+
1237
+ if (!config.repeat) {
1238
+ final OneTimeWorkRequest work = new OneTimeWorkRequest.Builder(workerClass)
1239
+ .setInitialDelay(config.interval, TimeUnit.MINUTES)
1240
+ .setInputData(data)
1241
+ .addTag(config.label)
1242
+ .setConstraints(constraints)
1243
+ .build();
1244
+ workManager.enqueueUniqueWork(config.label, ExistingWorkPolicy.REPLACE, work);
1245
+ } else {
1246
+ final PeriodicWorkRequest work = new PeriodicWorkRequest.Builder(workerClass, config.interval, TimeUnit.MINUTES)
1247
+ .setInitialDelay(config.interval, TimeUnit.MINUTES)
1248
+ .setInputData(data)
1249
+ .addTag(config.label)
1250
+ .setConstraints(constraints)
1251
+ .build();
1252
+ workManager.enqueueUniquePeriodicWork(config.label, ExistingPeriodicWorkPolicy.UPDATE, work);
1253
+ }
1254
+
1255
+ logger.info("Rescheduled Background Runner work after bundle switch.");
1256
+ } catch (ClassNotFoundException ignored) {
1257
+ logger.debug("Background Runner plugin not installed, skipping reschedule.");
1258
+ } catch (Exception e) {
1259
+ logger.warn("Failed to reschedule Background Runner work after bundle switch.");
1260
+ logger.debug("Background Runner reschedule error: " + e.getMessage());
1261
+ }
1061
1262
  }
1062
1263
 
1063
1264
  private boolean hasStoredBundleInfo(final String id) {
@@ -1070,6 +1271,27 @@ public class CapgoUpdater {
1070
1271
  );
1071
1272
  }
1072
1273
 
1274
+ private void runDownloadGate() throws IOException {
1275
+ if (this.downloadGate == null) {
1276
+ return;
1277
+ }
1278
+ try {
1279
+ this.downloadGate.run();
1280
+ } catch (final RuntimeException e) {
1281
+ throw new IOException(e.getMessage() == null ? "Download gate failed" : e.getMessage(), e);
1282
+ }
1283
+ }
1284
+
1285
+ private boolean runDownloadGateQuiet() {
1286
+ try {
1287
+ this.runDownloadGate();
1288
+ return true;
1289
+ } catch (final IOException e) {
1290
+ logger.error("Download blocked by cleanup gate: " + e.getMessage());
1291
+ return false;
1292
+ }
1293
+ }
1294
+
1073
1295
  public void downloadBackground(
1074
1296
  final String url,
1075
1297
  final String version,
@@ -1088,6 +1310,9 @@ public class CapgoUpdater {
1088
1310
  final JSONArray manifest,
1089
1311
  final boolean setNext
1090
1312
  ) {
1313
+ if (!this.runDownloadGateQuiet()) {
1314
+ return;
1315
+ }
1091
1316
  final String id = this.randomString();
1092
1317
 
1093
1318
  // Check if version is already downloading, but allow retry if previous download failed
@@ -1112,9 +1337,10 @@ public class CapgoUpdater {
1112
1337
  }
1113
1338
 
1114
1339
  public BundleInfo download(final String url, final String version, final String sessionKey, final String checksum) throws IOException {
1340
+ this.runDownloadGate();
1115
1341
  // Check for existing bundle with same version and clean up if in error state
1116
1342
  BundleInfo existingBundle = this.getBundleInfoByName(version);
1117
- if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted())) {
1343
+ if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted() || existingBundle.isDeleting())) {
1118
1344
  logger.info("Found existing failed bundle for version " + version + ", deleting before retry");
1119
1345
  this.delete(existingBundle.getId(), true);
1120
1346
  }
@@ -1160,13 +1386,14 @@ public class CapgoUpdater {
1160
1386
  final String checksum,
1161
1387
  final JSONArray manifest
1162
1388
  ) throws IOException {
1389
+ this.runDownloadGate();
1163
1390
  if (manifest == null) {
1164
1391
  return download(url, version, sessionKey, checksum);
1165
1392
  }
1166
1393
 
1167
1394
  // Check for existing bundle with same version and clean up if in error state
1168
1395
  BundleInfo existingBundle = this.getBundleInfoByName(version);
1169
- if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted())) {
1396
+ if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted() || existingBundle.isDeleting())) {
1170
1397
  logger.info("Found existing failed bundle for version " + version + ", deleting before retry");
1171
1398
  this.delete(existingBundle.getId(), true);
1172
1399
  }
@@ -1233,51 +1460,93 @@ public class CapgoUpdater {
1233
1460
  }
1234
1461
 
1235
1462
  public Boolean delete(final String id, final Boolean removeInfo) throws IOException {
1236
- final BundleInfo deleted = this.getBundleInfo(id);
1237
- if (deleted.isBuiltin() || this.getCurrentBundleId().equals(id)) {
1238
- logger.error("Cannot delete current or builtin bundle");
1239
- logger.debug("Bundle ID: " + id);
1240
- return false;
1241
- }
1242
- final BundleInfo previewFallback = this.getPreviewFallbackBundle();
1243
- if (
1244
- previewFallback != null &&
1245
- !previewFallback.isDeleted() &&
1246
- !previewFallback.isErrorStatus() &&
1247
- previewFallback.getId().equals(id)
1248
- ) {
1249
- logger.error("Cannot delete the preview fallback bundle");
1250
- logger.debug("Bundle ID: " + id);
1251
- return false;
1252
- }
1253
- final BundleInfo next = this.getNextBundle();
1254
- if (next != null && !next.isDeleted() && !next.isErrorStatus() && next.getId().equals(id)) {
1255
- logger.error("Cannot delete the next bundle");
1256
- logger.debug("Bundle ID: " + id);
1257
- return false;
1258
- }
1259
- // Cancel download for this version if active
1260
- if (this.activity != null) {
1261
- DownloadWorkerManager.cancelVersionDownload(this.activity, deleted.getVersionName());
1262
- }
1263
- final File bundle = new File(this.documentsDir, bundleDirectory + "/" + id);
1264
- if (bundle.exists()) {
1265
- this.deleteDirectory(bundle);
1266
- if (!removeInfo) {
1267
- this.saveBundleInfo(id, deleted.setStatus(BundleStatus.DELETED));
1463
+ return this.delete(id, removeInfo, true);
1464
+ }
1465
+
1466
+ public Boolean delete(final String id, final Boolean removeInfo, final boolean cancelActiveDownload) throws IOException {
1467
+ synchronized (this.deleteLock) {
1468
+ final BundleInfo deleted = this.getBundleInfo(id);
1469
+ if (deleted.isBuiltin() || this.getCurrentBundleId().equals(id)) {
1470
+ logger.error("Cannot delete current or builtin bundle");
1471
+ logger.debug("Bundle ID: " + id);
1472
+ return false;
1473
+ }
1474
+ final BundleInfo previewFallback = this.getPreviewFallbackBundle();
1475
+ if (
1476
+ previewFallback != null &&
1477
+ !previewFallback.isDeleted() &&
1478
+ !previewFallback.isErrorStatus() &&
1479
+ !previewFallback.isDeleting() &&
1480
+ previewFallback.getId().equals(id)
1481
+ ) {
1482
+ logger.error("Cannot delete the preview fallback bundle");
1483
+ logger.debug("Bundle ID: " + id);
1484
+ return false;
1485
+ }
1486
+ final BundleInfo next = this.getNextBundle();
1487
+ if (next != null && !next.isDeleted() && !next.isErrorStatus() && !next.isDeleting() && next.getId().equals(id)) {
1488
+ logger.error("Cannot delete the next bundle");
1489
+ logger.debug("Bundle ID: " + id);
1490
+ return false;
1491
+ }
1492
+
1493
+ final File bundle = this.getBundleDirectory(id);
1494
+ final boolean hadRegistry = this.hasStoredBundleInfo(id);
1495
+ final boolean hadFolder = bundle.exists();
1496
+ if (!hadRegistry && !hadFolder) {
1497
+ logger.error("Cannot delete unknown bundle");
1498
+ logger.debug("Bundle ID: " + id);
1499
+ return false;
1500
+ }
1501
+
1502
+ // Persist DELETING before touching disk so kill/OOM can resume on next launch.
1503
+ if (!deleted.isDeleting()) {
1504
+ if (!this.saveBundleInfo(id, deleted.setStatus(BundleStatus.DELETING))) {
1505
+ logger.error("Failed to persist DELETING marker, aborting disk delete");
1506
+ logger.debug("Bundle ID: " + id);
1507
+ return false;
1508
+ }
1509
+ }
1510
+
1511
+ // Cancel download for this version if active
1512
+ if (cancelActiveDownload && this.activity != null) {
1513
+ DownloadWorkerManager.cancelVersionDownload(this.activity, deleted.getVersionName());
1514
+ }
1515
+
1516
+ if (bundle.exists()) {
1517
+ try {
1518
+ this.deleteDirectory(bundle);
1519
+ } catch (final IOException e) {
1520
+ logger.error("Failed to delete bundle folder, will retry later");
1521
+ logger.debug("Bundle ID: " + id + ", Error: " + e.getMessage());
1522
+ return false;
1523
+ }
1524
+ }
1525
+
1526
+ // Only drop registry after the folder is confirmed gone.
1527
+ if (bundle.exists()) {
1528
+ logger.error("Bundle folder still present after delete, will retry later");
1529
+ logger.debug("Bundle ID: " + id);
1530
+ return false;
1531
+ }
1532
+
1533
+ final boolean finalized;
1534
+ if (Boolean.FALSE.equals(removeInfo)) {
1535
+ finalized = this.saveBundleInfo(id, deleted.setStatus(BundleStatus.DELETED));
1268
1536
  } else {
1269
- this.removeBundleInfo(id);
1537
+ finalized = this.saveBundleInfo(id, null);
1538
+ }
1539
+ if (!finalized) {
1540
+ logger.error("Failed to finalize delete registry update, will retry later");
1541
+ logger.debug("Bundle ID: " + id);
1542
+ return false;
1270
1543
  }
1544
+ this.sendStats("delete", deleted.getVersionName());
1545
+ this.dequeuePendingDelete(id);
1546
+ logger.info("Bundle deleted and confirmed gone");
1547
+ logger.debug("Bundle ID: " + id);
1271
1548
  return true;
1272
1549
  }
1273
- logger.info("Bundle not found on disk");
1274
- logger.debug("Version: " + deleted.getVersionName());
1275
- // perhaps we did not find the bundle in the files, but if the user requested a delete, we delete
1276
- if (removeInfo) {
1277
- this.removeBundleInfo(id);
1278
- }
1279
- this.sendStats("delete", deleted.getVersionName());
1280
- return false;
1281
1550
  }
1282
1551
 
1283
1552
  public Boolean delete(final String id) {
@@ -1290,6 +1559,82 @@ public class CapgoUpdater {
1290
1559
  }
1291
1560
  }
1292
1561
 
1562
+ /**
1563
+ * Resume incomplete deletes one-by-one. Safe across app kill / OOM because
1564
+ * delete() marks DELETING before disk work and only clears registry after confirm.
1565
+ */
1566
+ public void drainPendingDeletes() {
1567
+ final LinkedHashSet<String> pendingIds = new LinkedHashSet<>();
1568
+ for (final BundleInfo info : this.list(true)) {
1569
+ if (info != null && info.isDeleting() && info.getId() != null && !info.getId().isEmpty()) {
1570
+ pendingIds.add(info.getId());
1571
+ }
1572
+ }
1573
+ pendingIds.addAll(this.getPendingDeleteIds());
1574
+ for (final String id : pendingIds) {
1575
+ try {
1576
+ logger.info("Resuming pending delete for bundle: " + id);
1577
+ if (Boolean.TRUE.equals(this.delete(id, true))) {
1578
+ this.dequeuePendingDelete(id);
1579
+ }
1580
+ } catch (final Exception e) {
1581
+ logger.error("Pending delete failed, will retry next launch");
1582
+ logger.debug("Bundle ID: " + id + ", Error: " + e.getMessage());
1583
+ }
1584
+ try {
1585
+ Thread.sleep(DELETE_PACE_MS);
1586
+ } catch (final InterruptedException ie) {
1587
+ Thread.currentThread().interrupt();
1588
+ return;
1589
+ }
1590
+ }
1591
+ }
1592
+
1593
+ private Set<String> getPendingDeleteIds() {
1594
+ final Set<String> ids = new LinkedHashSet<>();
1595
+ if (this.prefs == null) {
1596
+ return ids;
1597
+ }
1598
+ final String raw = this.prefs.getString(PENDING_DELETE_IDS, "");
1599
+ if (raw == null || raw.isEmpty()) {
1600
+ return ids;
1601
+ }
1602
+ for (final String part : raw.split(",")) {
1603
+ if (part != null && !part.isEmpty()) {
1604
+ ids.add(part);
1605
+ }
1606
+ }
1607
+ return ids;
1608
+ }
1609
+
1610
+ private void enqueuePendingDelete(final String id) {
1611
+ if (id == null || id.isEmpty() || this.editor == null || this.prefs == null) {
1612
+ return;
1613
+ }
1614
+ final Set<String> ids = this.getPendingDeleteIds();
1615
+ if (!ids.add(id)) {
1616
+ return;
1617
+ }
1618
+ this.editor.putString(PENDING_DELETE_IDS, String.join(",", ids));
1619
+ this.editor.commit();
1620
+ }
1621
+
1622
+ private void dequeuePendingDelete(final String id) {
1623
+ if (id == null || id.isEmpty() || this.editor == null || this.prefs == null) {
1624
+ return;
1625
+ }
1626
+ final Set<String> ids = this.getPendingDeleteIds();
1627
+ if (!ids.remove(id)) {
1628
+ return;
1629
+ }
1630
+ if (ids.isEmpty()) {
1631
+ this.editor.remove(PENDING_DELETE_IDS);
1632
+ } else {
1633
+ this.editor.putString(PENDING_DELETE_IDS, String.join(",", ids));
1634
+ }
1635
+ this.editor.commit();
1636
+ }
1637
+
1293
1638
  private File getBundleDirectory(final String id) {
1294
1639
  return new File(this.documentsDir, bundleDirectory + "/" + id);
1295
1640
  }
@@ -1297,7 +1642,13 @@ public class CapgoUpdater {
1297
1642
  private boolean bundleExists(final String id) {
1298
1643
  final File bundle = this.getBundleDirectory(id);
1299
1644
  final BundleInfo bundleInfo = this.getBundleInfo(id);
1300
- return (bundle.isDirectory() && bundle.exists() && new File(bundle.getPath(), "/index.html").exists() && !bundleInfo.isDeleted());
1645
+ return (
1646
+ bundle.isDirectory() &&
1647
+ bundle.exists() &&
1648
+ new File(bundle.getPath(), "/index.html").exists() &&
1649
+ !bundleInfo.isDeleted() &&
1650
+ !bundleInfo.isDeleting()
1651
+ );
1301
1652
  }
1302
1653
 
1303
1654
  static final class ResetState {
@@ -1476,21 +1827,51 @@ public class CapgoUpdater {
1476
1827
  // Only attempt to delete when the fallback is a different bundle than the
1477
1828
  // currently loaded one. Otherwise we spam logs with "Cannot delete <id>"
1478
1829
  // because delete() protects the current bundle from removal.
1479
- if (
1480
- autoDeletePrevious &&
1830
+ final String previousFallbackId = fallback.getId();
1831
+ final String previousFallbackVersion = fallback.getVersionName();
1832
+ final BundleInfo nextBundle = this.getNextBundle();
1833
+ final boolean previousIsNext =
1834
+ nextBundle != null &&
1835
+ previousFallbackId != null &&
1836
+ previousFallbackId.equals(nextBundle.getId()) &&
1837
+ !nextBundle.isDeleted() &&
1838
+ !nextBundle.isErrorStatus() &&
1839
+ !nextBundle.isDeleting();
1840
+ final boolean shouldDeletePrevious =
1841
+ Boolean.TRUE.equals(autoDeletePrevious) &&
1481
1842
  !fallback.isBuiltin() &&
1482
- fallback.getId() != null &&
1483
- !fallback.getId().equals(bundle.getId()) &&
1484
- !fallbackIsPreviewFallback
1485
- ) {
1486
- final Boolean res = this.delete(fallback.getId());
1487
- if (res) {
1488
- logger.info("Deleted previous bundle: " + fallback.getVersionName());
1489
- } else {
1490
- logger.debug("Skip deleting previous bundle (same as current or protected): " + fallback.getId());
1843
+ previousFallbackId != null &&
1844
+ !previousFallbackId.equals(bundle.getId()) &&
1845
+ !fallbackIsPreviewFallback &&
1846
+ !previousIsNext;
1847
+ if (shouldDeletePrevious) {
1848
+ // Cancel any in-flight download for the old version before the async boundary
1849
+ // so a later download of the same version name is not cancelled mid-flight.
1850
+ if (this.activity != null) {
1851
+ DownloadWorkerManager.cancelVersionDownload(this.activity, previousFallbackVersion);
1852
+ }
1853
+ // Mark durable intent before fallback switch so a kill mid-flight still retries.
1854
+ if (!this.saveBundleInfo(previousFallbackId, fallback.setStatus(BundleStatus.DELETING))) {
1855
+ logger.error("Failed to persist DELETING for previous bundle; queueing durable retry");
1856
+ logger.debug("Bundle ID: " + previousFallbackId);
1857
+ this.enqueuePendingDelete(previousFallbackId);
1491
1858
  }
1492
1859
  }
1493
1860
  this.setFallbackBundle(bundle);
1861
+ if (shouldDeletePrevious) {
1862
+ new Thread(() -> {
1863
+ try {
1864
+ final Boolean res = this.delete(previousFallbackId, true, false);
1865
+ if (Boolean.TRUE.equals(res)) {
1866
+ logger.info("Deleted previous bundle: " + previousFallbackVersion);
1867
+ } else {
1868
+ logger.debug("Previous bundle delete incomplete, will retry: " + previousFallbackId);
1869
+ }
1870
+ } catch (final IOException e) {
1871
+ logger.error("Failed to delete previous bundle: " + previousFallbackId + " " + e.getMessage());
1872
+ }
1873
+ }, "CapgoUpdater-autoDeletePrevious").start();
1874
+ }
1494
1875
  }
1495
1876
 
1496
1877
  public void setError(final BundleInfo bundle) {
@@ -2333,10 +2714,10 @@ public class CapgoUpdater {
2333
2714
  this.saveBundleInfo(id, null);
2334
2715
  }
2335
2716
 
2336
- public void saveBundleInfo(final String id, final BundleInfo info) {
2717
+ public boolean saveBundleInfo(final String id, final BundleInfo info) {
2337
2718
  if (id == null || (info != null && (info.isBuiltin() || info.isUnknown()))) {
2338
2719
  logger.debug("Not saving info for bundle: [" + id + "] " + info);
2339
- return;
2720
+ return false;
2340
2721
  }
2341
2722
 
2342
2723
  if (info == null) {
@@ -2348,7 +2729,7 @@ public class CapgoUpdater {
2348
2729
  logger.debug("Storing info for bundle [" + id + "] " + update.getClass().getName() + " -> " + jsonString);
2349
2730
  this.editor.putString(id + INFO_SUFFIX, jsonString);
2350
2731
  }
2351
- this.editor.commit();
2732
+ return this.editor.commit();
2352
2733
  }
2353
2734
 
2354
2735
  private void setBundleStatus(final String id, final BundleStatus status) {