@capgo/capacitor-updater 6.34.0 → 6.37.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.
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> | |
@@ -295,7 +299,7 @@ In `capacitor.config.json`:
295
299
  {
296
300
  "plugins": {
297
301
  "CapacitorUpdater": {
298
- "appReadyTimeout": 1000 // (1 second),
302
+ "appReadyTimeout": 1000 // (1 second, minimum 1000),
299
303
  "responseTimeout": 10 // (10 second),
300
304
  "autoDeleteFailed": false,
301
305
  "autoDeletePrevious": false,
@@ -345,7 +349,7 @@ import { CapacitorConfig } from '@capacitor/cli';
345
349
  const config: CapacitorConfig = {
346
350
  plugins: {
347
351
  CapacitorUpdater: {
348
- appReadyTimeout: 1000 // (1 second),
352
+ appReadyTimeout: 1000 // (1 second, minimum 1000),
349
353
  responseTimeout: 10 // (10 second),
350
354
  autoDeleteFailed: false,
351
355
  autoDeletePrevious: false,
@@ -1771,6 +1775,8 @@ If you don't use backend, you need to provide the URL and version of the bundle.
1771
1775
  | **`old`** | <code>string</code> | The previous/current version name (provided for reference). | |
1772
1776
  | **`url`** | <code>string</code> | Download URL for the bundle (when a new version is available). | |
1773
1777
  | **`manifest`** | <code>ManifestEntry[]</code> | File list for partial updates (when using multi-file downloads). | 6.1 |
1778
+ | **`link`** | <code>string</code> | Optional link associated with this bundle version (e.g., release notes URL, changelog, GitHub release). | 7.35.0 |
1779
+ | **`comment`** | <code>string</code> | Optional comment or description for this bundle version. | 7.35.0 |
1774
1780
 
1775
1781
 
1776
1782
  ##### 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 = "6.27.11";
75
+ private final String pluginVersion = "6.37.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,10 +309,11 @@ 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", "");
308
- this.implementation.privateKey = this.getConfig().getString("privateKey", "");
309
- if (this.implementation.privateKey != null && !this.implementation.privateKey.isEmpty()) {
310
- this.implementation.hasOldPrivateKeyPropertyInConfig = true;
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);
311
317
  }
312
318
  this.implementation.statsUrl = this.getConfig().getString("statsUrl", statsUrlDefault);
313
319
  this.implementation.channelUrl = this.getConfig().getString("channelUrl", channelUrlDefault);
@@ -381,7 +387,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
381
387
  }
382
388
  }
383
389
  this.autoUpdate = this.getConfig().getBoolean("autoUpdate", true);
384
- this.appReadyTimeout = this.getConfig().getInt("appReadyTimeout", 10000);
390
+ this.appReadyTimeout = Math.max(1000, this.getConfig().getInt("appReadyTimeout", 10000)); // Minimum 1 second
385
391
  this.keepUrlPathAfterReload = this.getConfig().getBoolean("keepUrlPathAfterReload", false);
386
392
  this.syncKeepUrlPathFlag(this.keepUrlPathAfterReload);
387
393
  this.allowManualBundleError = this.getConfig().getBoolean("allowManualBundleError", false);
@@ -700,37 +706,80 @@ public class CapacitorUpdaterPlugin extends Plugin {
700
706
  }
701
707
 
702
708
  private void cleanupObsoleteVersions() {
703
- startNewThread(() -> {
704
- try {
705
- final String previous = this.prefs.getString("LatestNativeBuildVersion", "");
706
- if (!"".equals(previous) && !Objects.equals(this.currentBuildVersion, previous)) {
707
- logger.info("New native build version detected: " + this.currentBuildVersion);
708
- this.implementation.reset(true);
709
- final List<BundleInfo> installed = this.implementation.list(false);
710
- for (final BundleInfo bundle : installed) {
711
- try {
712
- logger.info("Deleting obsolete bundle: " + bundle.getId());
713
- this.implementation.delete(bundle.getId());
714
- } catch (final Exception e) {
715
- 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
+ }
716
729
  }
717
- }
718
- final List<BundleInfo> storedBundles = this.implementation.list(true);
719
- final Set<String> allowedIds = new HashSet<>();
720
- for (final BundleInfo info : storedBundles) {
721
- if (info != null && info.getId() != null && !info.getId().isEmpty()) {
722
- 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
+ }
723
736
  }
737
+ this.implementation.cleanupDownloadDirectories(allowedIds, Thread.currentThread());
738
+
739
+ // Check again before the expensive delta cache cleanup
740
+ if (Thread.currentThread().isInterrupted()) {
741
+ logger.warn("Cleanup was cancelled before delta cache cleanup");
742
+ return;
743
+ }
744
+ this.implementation.cleanupDeltaCache(Thread.currentThread());
724
745
  }
725
- this.implementation.cleanupDownloadDirectories(allowedIds);
726
- this.implementation.cleanupDeltaCache();
746
+ this.editor.putString("LatestNativeBuildVersion", this.currentBuildVersion);
747
+ this.editor.apply();
748
+ } catch (Exception e) {
749
+ logger.error("Error during cleanupObsoleteVersions: " + e.getMessage());
750
+ } finally {
751
+ cleanupComplete = true;
752
+ logger.info("Cleanup complete");
727
753
  }
728
- this.editor.putString("LatestNativeBuildVersion", this.currentBuildVersion);
729
- this.editor.apply();
730
- } catch (Exception e) {
731
- logger.error("Error during cleanupObsoleteVersions: " + e.getMessage());
732
754
  }
733
755
  });
756
+
757
+ // Start a timeout watchdog thread to cancel cleanup if it takes too long
758
+ final long timeout = this.appReadyTimeout / 2;
759
+ startNewThread(() -> {
760
+ try {
761
+ Thread.sleep(timeout);
762
+ if (cleanupThread != null && cleanupThread.isAlive() && !cleanupComplete) {
763
+ logger.warn("Cleanup timeout exceeded (" + timeout + "ms), interrupting cleanup thread");
764
+ cleanupThread.interrupt();
765
+ }
766
+ } catch (InterruptedException e) {
767
+ // Watchdog thread was interrupted, that's fine
768
+ }
769
+ });
770
+ }
771
+
772
+ private void waitForCleanupIfNeeded() {
773
+ if (cleanupComplete) {
774
+ return; // Already done, no need to wait
775
+ }
776
+
777
+ logger.info("Waiting for cleanup to complete before starting download...");
778
+
779
+ // Wait for cleanup to complete - blocks until lock is released
780
+ synchronized (cleanupLock) {
781
+ logger.info("Cleanup finished, proceeding with download");
782
+ }
734
783
  }
735
784
 
736
785
  public void notifyDownload(final String id, final int percent) {
@@ -1678,6 +1727,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
1678
1727
  ? "Update will occur now."
1679
1728
  : "Update will occur next time app moves to background.";
1680
1729
  return startNewThread(() -> {
1730
+ // Wait for cleanup to complete before starting download
1731
+ waitForCleanupIfNeeded();
1681
1732
  logger.info("Check for update via: " + CapacitorUpdaterPlugin.this.updateUrl);
1682
1733
  try {
1683
1734
  CapacitorUpdaterPlugin.this.implementation.getLatest(CapacitorUpdaterPlugin.this.updateUrl, null, (res) -> {
@@ -83,6 +83,9 @@ public class CapgoUpdater {
83
83
  public String deviceID = "";
84
84
  public int timeout = 20000;
85
85
 
86
+ // Cached key ID calculated once from publicKey
87
+ private String cachedKeyId = "";
88
+
86
89
  // Flag to track if we received a 429 response - stops requests until app restart
87
90
  private static volatile boolean rateLimitExceeded = false;
88
91
 
@@ -147,6 +150,19 @@ public class CapgoUpdater {
147
150
  return sb.toString();
148
151
  }
149
152
 
153
+ public void setPublicKey(String publicKey) {
154
+ this.publicKey = publicKey;
155
+ if (!publicKey.isEmpty()) {
156
+ this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
157
+ } else {
158
+ this.cachedKeyId = "";
159
+ }
160
+ }
161
+
162
+ public String getKeyId() {
163
+ return this.cachedKeyId;
164
+ }
165
+
150
166
  private File unzip(final String id, final File zipFile, final String dest) throws IOException {
151
167
  final File targetDirectory = new File(this.documentsDir, dest);
152
168
  try (
@@ -485,11 +501,20 @@ public class CapgoUpdater {
485
501
  }
486
502
 
487
503
  private void deleteDirectory(final File file) throws IOException {
504
+ deleteDirectory(file, null);
505
+ }
506
+
507
+ private void deleteDirectory(final File file, final Thread threadToCheck) throws IOException {
508
+ // Check if thread was interrupted (cancelled)
509
+ if (threadToCheck != null && threadToCheck.isInterrupted()) {
510
+ throw new IOException("Operation cancelled");
511
+ }
512
+
488
513
  if (file.isDirectory()) {
489
514
  final File[] entries = file.listFiles();
490
515
  if (entries != null) {
491
516
  for (final File entry : entries) {
492
- this.deleteDirectory(entry);
517
+ this.deleteDirectory(entry, threadToCheck);
493
518
  }
494
519
  }
495
520
  }
@@ -499,6 +524,10 @@ public class CapgoUpdater {
499
524
  }
500
525
 
501
526
  public void cleanupDeltaCache() {
527
+ cleanupDeltaCache(null);
528
+ }
529
+
530
+ public void cleanupDeltaCache(final Thread threadToCheck) {
502
531
  if (this.activity == null) {
503
532
  logger.warn("Activity is null, skipping delta cache cleanup");
504
533
  return;
@@ -508,7 +537,7 @@ public class CapgoUpdater {
508
537
  return;
509
538
  }
510
539
  try {
511
- this.deleteDirectory(cacheFolder);
540
+ this.deleteDirectory(cacheFolder, threadToCheck);
512
541
  logger.info("Cleaned up delta cache folder");
513
542
  } catch (IOException e) {
514
543
  logger.error("Failed to cleanup delta cache: " + e.getMessage());
@@ -516,6 +545,10 @@ public class CapgoUpdater {
516
545
  }
517
546
 
518
547
  public void cleanupDownloadDirectories(final Set<String> allowedIds) {
548
+ cleanupDownloadDirectories(allowedIds, null);
549
+ }
550
+
551
+ public void cleanupDownloadDirectories(final Set<String> allowedIds, final Thread threadToCheck) {
519
552
  if (this.documentsDir == null) {
520
553
  logger.warn("Documents directory is null, skipping download cleanup");
521
554
  return;
@@ -529,6 +562,12 @@ public class CapgoUpdater {
529
562
  final File[] entries = bundleRoot.listFiles();
530
563
  if (entries != null) {
531
564
  for (final File entry : entries) {
565
+ // Check if thread was interrupted (cancelled)
566
+ if (threadToCheck != null && threadToCheck.isInterrupted()) {
567
+ logger.warn("cleanupDownloadDirectories was cancelled");
568
+ return;
569
+ }
570
+
532
571
  if (!entry.isDirectory()) {
533
572
  continue;
534
573
  }
@@ -540,7 +579,7 @@ public class CapgoUpdater {
540
579
  }
541
580
 
542
581
  try {
543
- this.deleteDirectory(entry);
582
+ this.deleteDirectory(entry, threadToCheck);
544
583
  this.removeBundleInfo(id);
545
584
  logger.info("Deleted orphan bundle directory: " + id);
546
585
  } catch (IOException e) {
@@ -811,6 +850,12 @@ public class CapgoUpdater {
811
850
  json.put("is_emulator", this.isEmulator());
812
851
  json.put("is_prod", this.isProd());
813
852
  json.put("defaultChannel", this.defaultChannel);
853
+
854
+ // Add encryption key ID if encryption is enabled (use cached value)
855
+ if (!this.cachedKeyId.isEmpty()) {
856
+ json.put("key_id", this.cachedKeyId);
857
+ }
858
+
814
859
  return json;
815
860
  }
816
861
 
@@ -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
  }