@capgo/capacitor-updater 5.34.0 → 5.38.0

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.
@@ -11,7 +11,7 @@ Pod::Spec.new do |s|
11
11
  s.author = package['author']
12
12
  s.source = { :git => package['repository']['url'], :tag => s.version.to_s }
13
13
  s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
14
- s.ios.deployment_target = '14.0'
14
+ s.ios.deployment_target = '13.0'
15
15
  s.dependency 'Capacitor'
16
16
  s.dependency 'SSZipArchive', '2.4.3'
17
17
  s.dependency 'Alamofire', '5.10.2'
package/Package.swift CHANGED
@@ -3,7 +3,7 @@ import PackageDescription
3
3
 
4
4
  let package = Package(
5
5
  name: "CapgoCapacitorUpdater",
6
- platforms: [.iOS(.v14)],
6
+ platforms: [.iOS(.v13)],
7
7
  products: [
8
8
  .library(
9
9
  name: "CapgoCapacitorUpdater",
package/README.md CHANGED
@@ -66,6 +66,8 @@ The most complete [documentation here](https://capgo.app/docs/).
66
66
  ## Community
67
67
  Join the [discord](https://discord.gg/VnYRvBfgA6) to get help.
68
68
 
69
+ ## Migration to v8
70
+
69
71
  ## Migration to v7.34
70
72
 
71
73
  - **Channel storage change**: `setChannel()` now stores channel assignments locally on the device instead of in the cloud. This provides better offline support and reduces backend load.
@@ -76,13 +78,15 @@ Join the [discord](https://discord.gg/VnYRvBfgA6) to get help.
76
78
 
77
79
  ## Migration to v7
78
80
 
79
- - `privateKey` is not available anymore, it was used for the old encryption method. to migrate follow this guide : [https://capgo.app/docs/plugin/cloud-mode/getting-started/](https://capgo.app/docs/cli/migrations/encryption/)
80
- - To capacitor v7 : [https://capacitorjs.com/docs/updating/7-0](https://capacitorjs.com/docs/updating/7-0)
81
+ The min version of IOS is now 15.5 instead of 15 as Capacitor 8 requirement.
82
+ This is due to bump of ZipArchive to latest, a key dependency of this project is the zlib library. zlib before version 1.2.12 allows memory corruption when deflating (i.e., when compressing) if the input has many distant matches according to [CVE-2018-25032](https://nvd.nist.gov/vuln/detail/cve-2018-25032).
83
+ zlib is a native library so we need to bump the minimum iOS version to 15.5 as ZipArchive did the same in their latest versions.
81
84
 
82
85
  ## Compatibility
83
86
 
84
87
  | Plugin version | Capacitor compatibility | Maintained |
85
88
  | -------------- | ----------------------- | ----------------- |
89
+ | v8.\*.\* | v8.\*.\* | Beta |
86
90
  | v7.\*.\* | v7.\*.\* | ✅ |
87
91
  | v6.\*.\* | v6.\*.\* | ✅ |
88
92
  | v5.\*.\* | v5.\*.\* | ⚠️ Deprecated |
@@ -256,7 +260,7 @@ CapacitorUpdater can be configured with these options:
256
260
  | **`autoDeleteFailed`** | <code>boolean</code> | Configure whether the plugin should use automatically delete failed bundles. Only available for Android and iOS. | <code>true</code> | |
257
261
  | **`autoDeletePrevious`** | <code>boolean</code> | Configure whether the plugin should use automatically delete previous bundles after a successful update. Only available for Android and iOS. | <code>true</code> | |
258
262
  | **`autoUpdate`** | <code>boolean</code> | Configure whether the plugin should use Auto Update via an update server. Only available for Android and iOS. | <code>true</code> | |
259
- | **`resetWhenUpdate`** | <code>boolean</code> | Automatically delete previous downloaded bundles when a newer native app bundle is installed to the device. Only available for Android and iOS. | <code>true</code> | |
263
+ | **`resetWhenUpdate`** | <code>boolean</code> | Automatically delete previous downloaded bundles when a newer native app bundle is installed to the device. Setting this to false can broke the auto update flow if the user download from the store a native app bundle that is older than the current downloaded bundle. Upload will be prevented by channel setting downgrade_under_native. Only available for Android and iOS. | <code>true</code> | |
260
264
  | **`updateUrl`** | <code>string</code> | Configure the URL / endpoint to which update checks are sent. Only available for Android and iOS. | <code>https://plugin.capgo.app/updates</code> | |
261
265
  | **`channelUrl`** | <code>string</code> | Configure the URL / endpoint for channel operations. Only available for Android and iOS. | <code>https://plugin.capgo.app/channel_self</code> | |
262
266
  | **`statsUrl`** | <code>string</code> | Configure the URL / endpoint to which update statistics are sent. Only available for Android and iOS. Set to "" to disable stats reporting. | <code>https://plugin.capgo.app/stats</code> | |
@@ -294,7 +298,7 @@ In `capacitor.config.json`:
294
298
  {
295
299
  "plugins": {
296
300
  "CapacitorUpdater": {
297
- "appReadyTimeout": 1000 // (1 second),
301
+ "appReadyTimeout": 1000 // (1 second, minimum 1000),
298
302
  "responseTimeout": 10 // (10 second),
299
303
  "autoDeleteFailed": false,
300
304
  "autoDeletePrevious": false,
@@ -343,7 +347,7 @@ import { CapacitorConfig } from '@capacitor/cli';
343
347
  const config: CapacitorConfig = {
344
348
  plugins: {
345
349
  CapacitorUpdater: {
346
- appReadyTimeout: 1000 // (1 second),
350
+ appReadyTimeout: 1000 // (1 second, minimum 1000),
347
351
  responseTimeout: 10 // (10 second),
348
352
  autoDeleteFailed: false,
349
353
  autoDeletePrevious: false,
@@ -1768,6 +1772,8 @@ If you don't use backend, you need to provide the URL and version of the bundle.
1768
1772
  | **`old`** | <code>string</code> | The previous/current version name (provided for reference). | |
1769
1773
  | **`url`** | <code>string</code> | Download URL for the bundle (when a new version is available). | |
1770
1774
  | **`manifest`** | <code>ManifestEntry[]</code> | File list for partial updates (when using multi-file downloads). | 6.1 |
1775
+ | **`link`** | <code>string</code> | Optional link associated with this bundle version (e.g., release notes URL, changelog, GitHub release). | 7.35.0 |
1776
+ | **`comment`** | <code>string</code> | Optional comment or description for this bundle version. | 7.35.0 |
1771
1777
 
1772
1778
 
1773
1779
  ##### GetLatestOptions
@@ -29,25 +29,53 @@ public class BundleInfo {
29
29
  private final String version;
30
30
  private final String checksum;
31
31
  private final BundleStatus status;
32
+ private final String link;
33
+ private final String comment;
32
34
 
33
35
  static {
34
36
  sdf.setTimeZone(TimeZone.getTimeZone("GMT"));
35
37
  }
36
38
 
37
39
  public BundleInfo(final BundleInfo source) {
38
- this(source.id, source.version, source.status, source.downloaded, source.checksum);
40
+ this(source.id, source.version, source.status, source.downloaded, source.checksum, source.link, source.comment);
39
41
  }
40
42
 
41
43
  public BundleInfo(final String id, final String version, final BundleStatus status, final Date downloaded, final String checksum) {
42
- this(id, version, status, sdf.format(downloaded), checksum);
44
+ this(id, version, status, sdf.format(downloaded), checksum, null, null);
45
+ }
46
+
47
+ public BundleInfo(
48
+ final String id,
49
+ final String version,
50
+ final BundleStatus status,
51
+ final Date downloaded,
52
+ final String checksum,
53
+ final String link,
54
+ final String comment
55
+ ) {
56
+ this(id, version, status, sdf.format(downloaded), checksum, link, comment);
43
57
  }
44
58
 
45
59
  public BundleInfo(final String id, final String version, final BundleStatus status, final String downloaded, final String checksum) {
60
+ this(id, version, status, downloaded, checksum, null, null);
61
+ }
62
+
63
+ public BundleInfo(
64
+ final String id,
65
+ final String version,
66
+ final BundleStatus status,
67
+ final String downloaded,
68
+ final String checksum,
69
+ final String link,
70
+ final String comment
71
+ ) {
46
72
  this.downloaded = downloaded != null ? downloaded.trim() : "";
47
73
  this.id = id != null ? id : "";
48
74
  this.version = version;
49
75
  this.checksum = checksum != null ? checksum : "";
50
76
  this.status = status != null ? status : BundleStatus.ERROR;
77
+ this.link = link;
78
+ this.comment = comment;
51
79
  }
52
80
 
53
81
  public Boolean isBuiltin() {
@@ -75,7 +103,7 @@ public class BundleInfo {
75
103
  }
76
104
 
77
105
  public BundleInfo setDownloaded(Date downloaded) {
78
- return new BundleInfo(this.id, this.version, this.status, downloaded, this.checksum);
106
+ return new BundleInfo(this.id, this.version, this.status, downloaded, this.checksum, this.link, this.comment);
79
107
  }
80
108
 
81
109
  public String getChecksum() {
@@ -83,7 +111,7 @@ public class BundleInfo {
83
111
  }
84
112
 
85
113
  public BundleInfo setChecksum(String checksum) {
86
- return new BundleInfo(this.id, this.version, this.status, this.downloaded, checksum);
114
+ return new BundleInfo(this.id, this.version, this.status, this.downloaded, checksum, this.link, this.comment);
87
115
  }
88
116
 
89
117
  public String getId() {
@@ -91,7 +119,7 @@ public class BundleInfo {
91
119
  }
92
120
 
93
121
  public BundleInfo setId(String id) {
94
- return new BundleInfo(id, this.version, this.status, this.downloaded, this.checksum);
122
+ return new BundleInfo(id, this.version, this.status, this.downloaded, this.checksum, this.link, this.comment);
95
123
  }
96
124
 
97
125
  public String getVersionName() {
@@ -99,7 +127,7 @@ public class BundleInfo {
99
127
  }
100
128
 
101
129
  public BundleInfo setVersionName(String version) {
102
- return new BundleInfo(this.id, version, this.status, this.downloaded, this.checksum);
130
+ return new BundleInfo(this.id, version, this.status, this.downloaded, this.checksum, this.link, this.comment);
103
131
  }
104
132
 
105
133
  public BundleStatus getStatus() {
@@ -110,7 +138,23 @@ public class BundleInfo {
110
138
  }
111
139
 
112
140
  public BundleInfo setStatus(BundleStatus status) {
113
- return new BundleInfo(this.id, this.version, status, this.downloaded, this.checksum);
141
+ return new BundleInfo(this.id, this.version, status, this.downloaded, this.checksum, this.link, this.comment);
142
+ }
143
+
144
+ public String getLink() {
145
+ return this.link;
146
+ }
147
+
148
+ public BundleInfo setLink(String link) {
149
+ return new BundleInfo(this.id, this.version, this.status, this.downloaded, this.checksum, link, this.comment);
150
+ }
151
+
152
+ public String getComment() {
153
+ return this.comment;
154
+ }
155
+
156
+ public BundleInfo setComment(String comment) {
157
+ return new BundleInfo(this.id, this.version, this.status, this.downloaded, this.checksum, this.link, comment);
114
158
  }
115
159
 
116
160
  public static BundleInfo fromJSON(final String jsonString) throws JSONException {
@@ -120,7 +164,9 @@ public class BundleInfo {
120
164
  json.has("version") ? json.getString("version") : BundleInfo.VERSION_UNKNOWN,
121
165
  json.has("status") ? BundleStatus.fromString(json.getString("status")) : BundleStatus.PENDING,
122
166
  json.has("downloaded") ? json.getString("downloaded") : "",
123
- json.has("checksum") ? json.getString("checksum") : ""
167
+ json.has("checksum") ? json.getString("checksum") : "",
168
+ json.has("link") ? json.getString("link") : null,
169
+ json.has("comment") ? json.getString("comment") : null
124
170
  );
125
171
  }
126
172
 
@@ -131,6 +177,12 @@ public class BundleInfo {
131
177
  result.put("downloaded", this.getDownloaded());
132
178
  result.put("checksum", this.getChecksum());
133
179
  result.put("status", this.getStatus().toString());
180
+ if (this.link != null && !this.link.isEmpty()) {
181
+ result.put("link", this.link);
182
+ }
183
+ if (this.comment != null && !this.comment.isEmpty()) {
184
+ result.put("comment", this.comment);
185
+ }
134
186
  return result;
135
187
  }
136
188
 
@@ -72,7 +72,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
72
72
  private static final String[] BREAKING_EVENT_NAMES = { "breakingAvailable", "majorAvailable" };
73
73
  private static final String LAST_FAILED_BUNDLE_PREF_KEY = "CapacitorUpdater.lastFailedBundle";
74
74
 
75
- private final String pluginVersion = "5.31.0";
75
+ private final String pluginVersion = "5.38.0";
76
76
  private static final String DELAY_CONDITION_PREFERENCES = "";
77
77
 
78
78
  private SharedPreferences.Editor editor;
@@ -111,6 +111,11 @@ public class CapacitorUpdaterPlugin extends Plugin {
111
111
  // private static final CountDownLatch semaphoreReady = new CountDownLatch(1);
112
112
  private static final Phaser semaphoreReady = new Phaser(1);
113
113
 
114
+ // Lock to ensure cleanup completes before downloads start
115
+ private final Object cleanupLock = new Object();
116
+ private volatile boolean cleanupComplete = false;
117
+ private volatile Thread cleanupThread = null;
118
+
114
119
  private int lastNotifiedStatPercent = 0;
115
120
 
116
121
  private DelayUpdateUtils delayUpdateUtils;
@@ -304,7 +309,12 @@ public class CapacitorUpdaterPlugin extends Plugin {
304
309
  this.persistCustomId = this.getConfig().getBoolean("persistCustomId", false);
305
310
  this.persistModifyUrl = this.getConfig().getBoolean("persistModifyUrl", false);
306
311
  this.allowSetDefaultChannel = this.getConfig().getBoolean("allowSetDefaultChannel", true);
307
- this.implementation.publicKey = this.getConfig().getString("publicKey", "");
312
+ this.implementation.setPublicKey(this.getConfig().getString("publicKey", ""));
313
+ // Log public key prefix if encryption is enabled
314
+ String keyId = this.implementation.getKeyId();
315
+ if (keyId != null && !keyId.isEmpty()) {
316
+ logger.info("Public key prefix: " + keyId);
317
+ }
308
318
  this.implementation.statsUrl = this.getConfig().getString("statsUrl", statsUrlDefault);
309
319
  this.implementation.channelUrl = this.getConfig().getString("channelUrl", channelUrlDefault);
310
320
  if (Boolean.TRUE.equals(this.persistModifyUrl)) {
@@ -377,7 +387,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
377
387
  }
378
388
  }
379
389
  this.autoUpdate = this.getConfig().getBoolean("autoUpdate", true);
380
- this.appReadyTimeout = this.getConfig().getInt("appReadyTimeout", 10000);
390
+ this.appReadyTimeout = Math.max(1000, this.getConfig().getInt("appReadyTimeout", 10000)); // Minimum 1 second
381
391
  this.keepUrlPathAfterReload = this.getConfig().getBoolean("keepUrlPathAfterReload", false);
382
392
  this.syncKeepUrlPathFlag(this.keepUrlPathAfterReload);
383
393
  this.allowManualBundleError = this.getConfig().getBoolean("allowManualBundleError", false);
@@ -696,37 +706,81 @@ public class CapacitorUpdaterPlugin extends Plugin {
696
706
  }
697
707
 
698
708
  private void cleanupObsoleteVersions() {
699
- startNewThread(() -> {
700
- try {
701
- final String previous = this.prefs.getString("LatestNativeBuildVersion", "");
702
- if (!"".equals(previous) && !Objects.equals(this.currentBuildVersion, previous)) {
703
- logger.info("New native build version detected: " + this.currentBuildVersion);
704
- this.implementation.reset(true);
705
- final List<BundleInfo> installed = this.implementation.list(false);
706
- for (final BundleInfo bundle : installed) {
707
- try {
708
- logger.info("Deleting obsolete bundle: " + bundle.getId());
709
- this.implementation.delete(bundle.getId());
710
- } catch (final Exception e) {
711
- logger.error("Failed to delete: " + bundle.getId() + " " + e.getMessage());
709
+ cleanupThread = startNewThread(() -> {
710
+ synchronized (cleanupLock) {
711
+ try {
712
+ final String previous = this.prefs.getString("LatestNativeBuildVersion", "");
713
+ if (!"".equals(previous) && !Objects.equals(this.currentBuildVersion, previous)) {
714
+ logger.info("New native build version detected: " + this.currentBuildVersion);
715
+ this.implementation.reset(true);
716
+ final List<BundleInfo> installed = this.implementation.list(false);
717
+ for (final BundleInfo bundle : installed) {
718
+ // Check if thread was interrupted (cancelled)
719
+ if (Thread.currentThread().isInterrupted()) {
720
+ logger.warn("Cleanup was cancelled, stopping");
721
+ return;
722
+ }
723
+ try {
724
+ logger.info("Deleting obsolete bundle: " + bundle.getId());
725
+ this.implementation.delete(bundle.getId());
726
+ } catch (final Exception e) {
727
+ logger.error("Failed to delete: " + bundle.getId() + " " + e.getMessage());
728
+ }
712
729
  }
713
- }
714
- final List<BundleInfo> storedBundles = this.implementation.list(true);
715
- final Set<String> allowedIds = new HashSet<>();
716
- for (final BundleInfo info : storedBundles) {
717
- if (info != null && info.getId() != null && !info.getId().isEmpty()) {
718
- allowedIds.add(info.getId());
730
+ final List<BundleInfo> storedBundles = this.implementation.list(true);
731
+ final Set<String> allowedIds = new HashSet<>();
732
+ for (final BundleInfo info : storedBundles) {
733
+ if (info != null && info.getId() != null && !info.getId().isEmpty()) {
734
+ allowedIds.add(info.getId());
735
+ }
719
736
  }
737
+ this.implementation.cleanupDownloadDirectories(allowedIds, Thread.currentThread());
738
+ this.implementation.cleanupOrphanedTempFolders(Thread.currentThread());
739
+
740
+ // Check again before the expensive delta cache cleanup
741
+ if (Thread.currentThread().isInterrupted()) {
742
+ logger.warn("Cleanup was cancelled before delta cache cleanup");
743
+ return;
744
+ }
745
+ this.implementation.cleanupDeltaCache(Thread.currentThread());
720
746
  }
721
- this.implementation.cleanupDownloadDirectories(allowedIds);
722
- this.implementation.cleanupDeltaCache();
747
+ this.editor.putString("LatestNativeBuildVersion", this.currentBuildVersion);
748
+ this.editor.apply();
749
+ } catch (Exception e) {
750
+ logger.error("Error during cleanupObsoleteVersions: " + e.getMessage());
751
+ } finally {
752
+ cleanupComplete = true;
753
+ logger.info("Cleanup complete");
723
754
  }
724
- this.editor.putString("LatestNativeBuildVersion", this.currentBuildVersion);
725
- this.editor.apply();
726
- } catch (Exception e) {
727
- logger.error("Error during cleanupObsoleteVersions: " + e.getMessage());
728
755
  }
729
756
  });
757
+
758
+ // Start a timeout watchdog thread to cancel cleanup if it takes too long
759
+ final long timeout = this.appReadyTimeout / 2;
760
+ startNewThread(() -> {
761
+ try {
762
+ Thread.sleep(timeout);
763
+ if (cleanupThread != null && cleanupThread.isAlive() && !cleanupComplete) {
764
+ logger.warn("Cleanup timeout exceeded (" + timeout + "ms), interrupting cleanup thread");
765
+ cleanupThread.interrupt();
766
+ }
767
+ } catch (InterruptedException e) {
768
+ // Watchdog thread was interrupted, that's fine
769
+ }
770
+ });
771
+ }
772
+
773
+ private void waitForCleanupIfNeeded() {
774
+ if (cleanupComplete) {
775
+ return; // Already done, no need to wait
776
+ }
777
+
778
+ logger.info("Waiting for cleanup to complete before starting download...");
779
+
780
+ // Wait for cleanup to complete - blocks until lock is released
781
+ synchronized (cleanupLock) {
782
+ logger.info("Cleanup finished, proceeding with download");
783
+ }
730
784
  }
731
785
 
732
786
  public void notifyDownload(final String id, final int percent) {
@@ -1674,6 +1728,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
1674
1728
  ? "Update will occur now."
1675
1729
  : "Update will occur next time app moves to background.";
1676
1730
  return startNewThread(() -> {
1731
+ // Wait for cleanup to complete before starting download
1732
+ waitForCleanupIfNeeded();
1677
1733
  logger.info("Check for update via: " + CapacitorUpdaterPlugin.this.updateUrl);
1678
1734
  try {
1679
1735
  CapacitorUpdaterPlugin.this.implementation.getLatest(CapacitorUpdaterPlugin.this.updateUrl, null, (res) -> {
@@ -58,6 +58,7 @@ public class CapgoUpdater {
58
58
  private static final String FALLBACK_VERSION = "pastVersion";
59
59
  private static final String NEXT_VERSION = "nextVersion";
60
60
  private static final String bundleDirectory = "versions";
61
+ private static final String TEMP_UNZIP_PREFIX = "capgo_unzip_";
61
62
 
62
63
  public static final String TAG = "Capacitor-updater";
63
64
  public SharedPreferences.Editor editor;
@@ -81,6 +82,9 @@ public class CapgoUpdater {
81
82
  public String deviceID = "";
82
83
  public int timeout = 20000;
83
84
 
85
+ // Cached key ID calculated once from publicKey
86
+ private String cachedKeyId = "";
87
+
84
88
  // Flag to track if we received a 429 response - stops requests until app restart
85
89
  private static volatile boolean rateLimitExceeded = false;
86
90
 
@@ -145,6 +149,19 @@ public class CapgoUpdater {
145
149
  return sb.toString();
146
150
  }
147
151
 
152
+ public void setPublicKey(String publicKey) {
153
+ this.publicKey = publicKey;
154
+ if (!publicKey.isEmpty()) {
155
+ this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
156
+ } else {
157
+ this.cachedKeyId = "";
158
+ }
159
+ }
160
+
161
+ public String getKeyId() {
162
+ return this.cachedKeyId;
163
+ }
164
+
148
165
  private File unzip(final String id, final File zipFile, final String dest) throws IOException {
149
166
  final File targetDirectory = new File(this.documentsDir, dest);
150
167
  try (
@@ -431,7 +448,7 @@ public class CapgoUpdater {
431
448
 
432
449
  try {
433
450
  if (!isManifest) {
434
- extractedDir = this.unzip(id, downloaded, this.randomString());
451
+ extractedDir = this.unzip(id, downloaded, TEMP_UNZIP_PREFIX + this.randomString());
435
452
  this.notifyDownload(id, 91);
436
453
  final String idName = bundleDirectory + "/" + id;
437
454
  this.flattenAssets(extractedDir, idName);
@@ -480,11 +497,20 @@ public class CapgoUpdater {
480
497
  }
481
498
 
482
499
  private void deleteDirectory(final File file) throws IOException {
500
+ deleteDirectory(file, null);
501
+ }
502
+
503
+ private void deleteDirectory(final File file, final Thread threadToCheck) throws IOException {
504
+ // Check if thread was interrupted (cancelled)
505
+ if (threadToCheck != null && threadToCheck.isInterrupted()) {
506
+ throw new IOException("Operation cancelled");
507
+ }
508
+
483
509
  if (file.isDirectory()) {
484
510
  final File[] entries = file.listFiles();
485
511
  if (entries != null) {
486
512
  for (final File entry : entries) {
487
- this.deleteDirectory(entry);
513
+ this.deleteDirectory(entry, threadToCheck);
488
514
  }
489
515
  }
490
516
  }
@@ -494,6 +520,10 @@ public class CapgoUpdater {
494
520
  }
495
521
 
496
522
  public void cleanupDeltaCache() {
523
+ cleanupDeltaCache(null);
524
+ }
525
+
526
+ public void cleanupDeltaCache(final Thread threadToCheck) {
497
527
  if (this.activity == null) {
498
528
  logger.warn("Activity is null, skipping delta cache cleanup");
499
529
  return;
@@ -503,7 +533,7 @@ public class CapgoUpdater {
503
533
  return;
504
534
  }
505
535
  try {
506
- this.deleteDirectory(cacheFolder);
536
+ this.deleteDirectory(cacheFolder, threadToCheck);
507
537
  logger.info("Cleaned up delta cache folder");
508
538
  } catch (IOException e) {
509
539
  logger.error("Failed to cleanup delta cache: " + e.getMessage());
@@ -511,6 +541,10 @@ public class CapgoUpdater {
511
541
  }
512
542
 
513
543
  public void cleanupDownloadDirectories(final Set<String> allowedIds) {
544
+ cleanupDownloadDirectories(allowedIds, null);
545
+ }
546
+
547
+ public void cleanupDownloadDirectories(final Set<String> allowedIds, final Thread threadToCheck) {
514
548
  if (this.documentsDir == null) {
515
549
  logger.warn("Documents directory is null, skipping download cleanup");
516
550
  return;
@@ -524,6 +558,12 @@ public class CapgoUpdater {
524
558
  final File[] entries = bundleRoot.listFiles();
525
559
  if (entries != null) {
526
560
  for (final File entry : entries) {
561
+ // Check if thread was interrupted (cancelled)
562
+ if (threadToCheck != null && threadToCheck.isInterrupted()) {
563
+ logger.warn("cleanupDownloadDirectories was cancelled");
564
+ return;
565
+ }
566
+
527
567
  if (!entry.isDirectory()) {
528
568
  continue;
529
569
  }
@@ -535,7 +575,7 @@ public class CapgoUpdater {
535
575
  }
536
576
 
537
577
  try {
538
- this.deleteDirectory(entry);
578
+ this.deleteDirectory(entry, threadToCheck);
539
579
  this.removeBundleInfo(id);
540
580
  logger.info("Deleted orphan bundle directory: " + id);
541
581
  } catch (IOException e) {
@@ -545,6 +585,44 @@ public class CapgoUpdater {
545
585
  }
546
586
  }
547
587
 
588
+ public void cleanupOrphanedTempFolders(final Thread threadToCheck) {
589
+ if (this.documentsDir == null) {
590
+ logger.warn("Documents directory is null, skipping temp folder cleanup");
591
+ return;
592
+ }
593
+
594
+ final File[] entries = this.documentsDir.listFiles();
595
+ if (entries == null) {
596
+ return;
597
+ }
598
+
599
+ for (final File entry : entries) {
600
+ // Check if thread was interrupted (cancelled)
601
+ if (threadToCheck != null && threadToCheck.isInterrupted()) {
602
+ logger.warn("cleanupOrphanedTempFolders was cancelled");
603
+ return;
604
+ }
605
+
606
+ if (!entry.isDirectory()) {
607
+ continue;
608
+ }
609
+
610
+ final String folderName = entry.getName();
611
+
612
+ // Only delete folders with the temp unzip prefix
613
+ if (!folderName.startsWith(TEMP_UNZIP_PREFIX)) {
614
+ continue;
615
+ }
616
+
617
+ try {
618
+ this.deleteDirectory(entry, threadToCheck);
619
+ logger.info("Deleted orphaned temp unzip folder: " + folderName);
620
+ } catch (IOException e) {
621
+ logger.error("Failed to delete orphaned temp folder: " + folderName + " " + e.getMessage());
622
+ }
623
+ }
624
+ }
625
+
548
626
  private void safeDelete(final File target) {
549
627
  if (target == null || !target.exists()) {
550
628
  return;
@@ -806,6 +884,12 @@ public class CapgoUpdater {
806
884
  json.put("is_emulator", this.isEmulator());
807
885
  json.put("is_prod", this.isProd());
808
886
  json.put("defaultChannel", this.defaultChannel);
887
+
888
+ // Add encryption key ID if encryption is enabled (use cached value)
889
+ if (!this.cachedKeyId.isEmpty()) {
890
+ json.put("key_id", this.cachedKeyId);
891
+ }
892
+
809
893
  return json;
810
894
  }
811
895
 
@@ -359,4 +359,23 @@ public class CryptoCipher {
359
359
 
360
360
  throw new IllegalArgumentException("size too large, only up to 64KiB length encoding supported: " + size);
361
361
  }
362
+
363
+ /**
364
+ * Get first 4 characters of the public key for identification.
365
+ * Returns 4-character string or empty string if key is invalid/empty.
366
+ */
367
+ public static String calcKeyId(String publicKey) {
368
+ if (publicKey == null || publicKey.isEmpty()) {
369
+ return "";
370
+ }
371
+
372
+ // Remove PEM headers and whitespace to get the raw key data
373
+ String cleanedKey = publicKey
374
+ .replaceAll("\\s+", "")
375
+ .replace("-----BEGINRSAPUBLICKEY-----", "")
376
+ .replace("-----ENDRSAPUBLICKEY-----", "");
377
+
378
+ // Return first 4 characters of the base64-encoded key
379
+ return cleanedKey.length() >= 4 ? cleanedKey.substring(0, 4) : cleanedKey;
380
+ }
362
381
  }