@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.
- package/README.md +44 -42
- package/android/build.gradle +1 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +7 -3
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +1 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +251 -93
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +450 -69
- package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +2 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +19 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +90 -116
- package/dist/docs.json +13 -5
- package/dist/esm/definitions.d.ts +15 -10
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +5 -1
- package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +167 -49
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +188 -34
- package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
- package/package.json +6 -2
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1193
|
+
final BackgroundRunnerWorkConfig config;
|
|
1041
1194
|
try {
|
|
1042
|
-
|
|
1195
|
+
config = getBackgroundRunnerWorkConfigFromConfig(this.readAssetAsString(CAPACITOR_CONFIG_ASSET));
|
|
1043
1196
|
} catch (IOException ignored) {
|
|
1044
1197
|
return;
|
|
1045
1198
|
}
|
|
1046
1199
|
|
|
1047
|
-
if (
|
|
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
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
previewFallback.
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
1480
|
-
|
|
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
|
-
|
|
1483
|
-
!
|
|
1484
|
-
!fallbackIsPreviewFallback
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
|
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) {
|