@capgo/capacitor-updater 6.2.9 → 6.3.3

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.
@@ -58,4 +58,5 @@ dependencies {
58
58
  testImplementation "junit:junit:$junitVersion"
59
59
  androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
60
60
  androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
61
+ implementation 'org.brotli:dec:0.1.2'
61
62
  }
@@ -54,6 +54,7 @@ import java.util.zip.CRC32;
54
54
  import java.util.zip.ZipEntry;
55
55
  import java.util.zip.ZipInputStream;
56
56
  import javax.crypto.SecretKey;
57
+ import org.json.JSONArray;
57
58
  import org.json.JSONException;
58
59
  import org.json.JSONObject;
59
60
 
@@ -283,6 +284,10 @@ public class CapacitorUpdater {
283
284
  String sessionKey = bundle.getString(DownloadService.SESSIONKEY);
284
285
  String checksum = bundle.getString(DownloadService.CHECKSUM);
285
286
  String error = bundle.getString(DownloadService.ERROR);
287
+ boolean isManifest = bundle.getBoolean(
288
+ DownloadService.IS_MANIFEST,
289
+ false
290
+ );
286
291
  Log.i(
287
292
  CapacitorUpdater.TAG,
288
293
  "res " +
@@ -318,7 +323,8 @@ public class CapacitorUpdater {
318
323
  version,
319
324
  sessionKey,
320
325
  checksum,
321
- true
326
+ true,
327
+ isManifest
322
328
  );
323
329
  } else {
324
330
  Log.i(TAG, "Unknown action " + action);
@@ -353,35 +359,39 @@ public class CapacitorUpdater {
353
359
  String version,
354
360
  String sessionKey,
355
361
  String checksumRes,
356
- Boolean setNext
362
+ Boolean setNext,
363
+ Boolean isManifest
357
364
  ) {
358
365
  File downloaded = null;
359
- String checksum;
366
+ String checksum = "";
360
367
 
361
368
  try {
362
369
  this.notifyDownload(id, 71);
363
370
  downloaded = new File(this.documentsDir, dest);
364
371
 
365
- String checksumDecrypted = Objects.requireNonNullElse(checksumRes, "");
366
- if (!this.hasOldPrivateKeyPropertyInConfig && !sessionKey.isEmpty()) {
367
- this.decryptFileV2(downloaded, sessionKey, version);
368
- checksumDecrypted = this.decryptChecksum(checksumRes, version);
369
- checksum = this.calcChecksumV2(downloaded);
370
- } else {
371
- this.decryptFile(downloaded, sessionKey, version);
372
- checksum = this.calcChecksum(downloaded);
373
- }
374
- if (
375
- (!checksumDecrypted.isEmpty() || !this.publicKey.isEmpty()) &&
376
- !checksumDecrypted.equals(checksum)
377
- ) {
378
- Log.e(
379
- CapacitorUpdater.TAG,
380
- "Error checksum '" + checksumDecrypted + "' '" + checksum + "' '"
381
- );
382
- this.sendStats("checksum_fail");
383
- throw new IOException("Checksum failed: " + id);
372
+ if (!isManifest) {
373
+ String checksumDecrypted = Objects.requireNonNullElse(checksumRes, "");
374
+ if (!this.hasOldPrivateKeyPropertyInConfig && !sessionKey.isEmpty()) {
375
+ this.decryptFileV2(downloaded, sessionKey, version);
376
+ checksumDecrypted = this.decryptChecksum(checksumRes, version);
377
+ checksum = this.calcChecksumV2(downloaded);
378
+ } else {
379
+ this.decryptFile(downloaded, sessionKey, version);
380
+ checksum = this.calcChecksum(downloaded);
381
+ }
382
+ if (
383
+ (!checksumDecrypted.isEmpty() || !this.publicKey.isEmpty()) &&
384
+ !checksumDecrypted.equals(checksum)
385
+ ) {
386
+ Log.e(
387
+ CapacitorUpdater.TAG,
388
+ "Error checksum '" + checksumDecrypted + "' '" + checksum + "' '"
389
+ );
390
+ this.sendStats("checksum_fail");
391
+ throw new IOException("Checksum failed: " + id);
392
+ }
384
393
  }
394
+ // Remove the decryption for manifest downloads
385
395
  } catch (IOException e) {
386
396
  final Boolean res = this.delete(id);
387
397
  if (!res) {
@@ -400,11 +410,17 @@ public class CapacitorUpdater {
400
410
  }
401
411
 
402
412
  try {
403
- final File unzipped = this.unzip(id, downloaded, this.randomString());
404
- downloaded.delete();
405
- this.notifyDownload(id, 91);
406
- final String idName = bundleDirectory + "/" + id;
407
- this.flattenAssets(unzipped, idName);
413
+ if (!isManifest) {
414
+ final File unzipped = this.unzip(id, downloaded, this.randomString());
415
+ this.notifyDownload(id, 91);
416
+ final String idName = bundleDirectory + "/" + id;
417
+ this.flattenAssets(unzipped, idName);
418
+ } else {
419
+ this.notifyDownload(id, 91);
420
+ final String idName = bundleDirectory + "/" + id;
421
+ this.flattenAssets(downloaded, idName);
422
+ downloaded.delete();
423
+ }
408
424
  this.notifyDownload(id, 100);
409
425
  this.saveBundleInfo(id, null);
410
426
  BundleInfo next = new BundleInfo(
@@ -447,7 +463,8 @@ public class CapacitorUpdater {
447
463
  final String version,
448
464
  final String sessionKey,
449
465
  final String checksum,
450
- final String dest
466
+ final String dest,
467
+ final JSONArray manifest
451
468
  ) {
452
469
  Intent intent = new Intent(this.activity, DownloadService.class);
453
470
  intent.putExtra(DownloadService.URL, url);
@@ -460,6 +477,9 @@ public class CapacitorUpdater {
460
477
  intent.putExtra(DownloadService.VERSION, version);
461
478
  intent.putExtra(DownloadService.SESSIONKEY, sessionKey);
462
479
  intent.putExtra(DownloadService.CHECKSUM, checksum);
480
+ if (manifest != null) {
481
+ intent.putExtra(DownloadService.MANIFEST, manifest.toString());
482
+ }
463
483
  this.activity.startService(intent);
464
484
  }
465
485
 
@@ -702,7 +722,8 @@ public class CapacitorUpdater {
702
722
  final String url,
703
723
  final String version,
704
724
  final String sessionKey,
705
- final String checksum
725
+ final String checksum,
726
+ final JSONArray manifest
706
727
  ) {
707
728
  final String id = this.randomString();
708
729
  this.saveBundleInfo(
@@ -717,13 +738,15 @@ public class CapacitorUpdater {
717
738
  );
718
739
  this.notifyDownload(id, 0);
719
740
  this.notifyDownload(id, 5);
741
+
720
742
  this.downloadFileBackground(
721
743
  id,
722
744
  url,
723
745
  version,
724
746
  sessionKey,
725
747
  checksum,
726
- this.randomString()
748
+ this.randomString(),
749
+ manifest
727
750
  );
728
751
  }
729
752
 
@@ -749,7 +772,15 @@ public class CapacitorUpdater {
749
772
  final String dest = this.randomString();
750
773
  this.downloadFile(id, url, dest);
751
774
  final Boolean finished =
752
- this.finishDownload(id, dest, version, sessionKey, checksum, false);
775
+ this.finishDownload(
776
+ id,
777
+ dest,
778
+ version,
779
+ sessionKey,
780
+ checksum,
781
+ false,
782
+ false
783
+ );
753
784
  final BundleStatus status = finished
754
785
  ? BundleStatus.PENDING
755
786
  : BundleStatus.ERROR;
@@ -42,6 +42,7 @@ import java.util.UUID;
42
42
  import java.util.concurrent.Phaser;
43
43
  import java.util.concurrent.TimeUnit;
44
44
  import java.util.concurrent.TimeoutException;
45
+ import org.json.JSONArray;
45
46
  import org.json.JSONException;
46
47
 
47
48
  @CapacitorPlugin(name = "CapacitorUpdater")
@@ -53,7 +54,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
53
54
  private static final String channelUrlDefault =
54
55
  "https://api.capgo.app/channel_self";
55
56
 
56
- private final String PLUGIN_VERSION = "6.2.9";
57
+ private final String PLUGIN_VERSION = "6.3.3";
57
58
  private static final String DELAY_CONDITION_PREFERENCES = "";
58
59
 
59
60
  private SharedPreferences.Editor editor;
@@ -79,6 +80,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
79
80
  // private static final CountDownLatch semaphoreReady = new CountDownLatch(1);
80
81
  private static final Phaser semaphoreReady = new Phaser(1);
81
82
 
83
+ private int lastNotifiedStatPercent = 0;
84
+
82
85
  public Thread startNewThread(final Runnable function, Number waitTime) {
83
86
  Thread bgTask = new Thread(() -> {
84
87
  try {
@@ -351,6 +354,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
351
354
  final BundleInfo bundleInfo = this.implementation.getBundleInfo(id);
352
355
  ret.put("bundle", bundleInfo.toJSON());
353
356
  this.notifyListeners("download", ret);
357
+
354
358
  if (percent == 100) {
355
359
  final JSObject retDownloadComplete = new JSObject(
356
360
  ret,
@@ -361,11 +365,16 @@ public class CapacitorUpdaterPlugin extends Plugin {
361
365
  "download_complete",
362
366
  bundleInfo.getVersionName()
363
367
  );
364
- } else if (percent % 10 == 0) {
365
- this.implementation.sendStats(
366
- "download_" + percent,
367
- bundleInfo.getVersionName()
368
- );
368
+ lastNotifiedStatPercent = 100;
369
+ } else {
370
+ int currentStatPercent = (percent / 10) * 10; // Round down to nearest 10
371
+ if (currentStatPercent > lastNotifiedStatPercent) {
372
+ this.implementation.sendStats(
373
+ "download_" + currentStatPercent,
374
+ bundleInfo.getVersionName()
375
+ );
376
+ lastNotifiedStatPercent = currentStatPercent;
377
+ }
369
378
  }
370
379
  } catch (final Exception e) {
371
380
  Log.e(CapacitorUpdater.TAG, "Could not notify listeners", e);
@@ -508,7 +517,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
508
517
  }
509
518
  call.resolve(res);
510
519
  }
511
- }));
520
+ })
521
+ );
512
522
  } catch (final Exception e) {
513
523
  Log.e(CapacitorUpdater.TAG, "Failed to unsetChannel: ", e);
514
524
  call.reject("Failed to unsetChannel: ", e);
@@ -550,7 +560,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
550
560
  }
551
561
  call.resolve(res);
552
562
  }
553
- }));
563
+ })
564
+ );
554
565
  } catch (final Exception e) {
555
566
  Log.e(CapacitorUpdater.TAG, "Failed to setChannel: " + channel, e);
556
567
  call.reject("Failed to setChannel: " + channel, e);
@@ -568,7 +579,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
568
579
  } else {
569
580
  call.resolve(res);
570
581
  }
571
- }));
582
+ })
583
+ );
572
584
  } catch (final Exception e) {
573
585
  Log.e(CapacitorUpdater.TAG, "Failed to getChannel", e);
574
586
  call.reject("Failed to getChannel", e);
@@ -787,7 +799,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
787
799
  }
788
800
  call.resolve(ret);
789
801
  }
790
- ));
802
+ )
803
+ );
791
804
  }
792
805
 
793
806
  private boolean _reset(final Boolean toLastSuccessful) {
@@ -1298,12 +1311,27 @@ public class CapacitorUpdaterPlugin extends Plugin {
1298
1311
  final String checksum = res.has("checksum")
1299
1312
  ? res.getString("checksum")
1300
1313
  : "";
1301
- CapacitorUpdaterPlugin.this.implementation.downloadBackground(
1302
- url,
1303
- latestVersionName,
1304
- sessionKey,
1305
- checksum
1306
- );
1314
+
1315
+ if (res.has("manifest")) {
1316
+ // Handle manifest-based download
1317
+ JSONArray manifest = res.getJSONArray("manifest");
1318
+ CapacitorUpdaterPlugin.this.implementation.downloadBackground(
1319
+ url,
1320
+ latestVersionName,
1321
+ sessionKey,
1322
+ checksum,
1323
+ manifest
1324
+ );
1325
+ } else {
1326
+ // Handle single file download (existing code)
1327
+ CapacitorUpdaterPlugin.this.implementation.downloadBackground(
1328
+ url,
1329
+ latestVersionName,
1330
+ sessionKey,
1331
+ checksum,
1332
+ null
1333
+ );
1334
+ }
1307
1335
  } catch (final Exception e) {
1308
1336
  Log.e(CapacitorUpdater.TAG, "error downloading file", e);
1309
1337
  CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
@@ -7,12 +7,30 @@ package ee.forgr.capacitor_updater;
7
7
 
8
8
  import android.app.IntentService;
9
9
  import android.content.Intent;
10
+ import android.util.Log;
11
+ import com.android.volley.DefaultRetryPolicy;
12
+ import com.android.volley.NetworkResponse;
13
+ import com.android.volley.Request;
14
+ import com.android.volley.Response;
15
+ import com.android.volley.toolbox.HttpHeaderParser;
16
+ import com.android.volley.toolbox.Volley;
10
17
  import java.io.*;
18
+ import java.io.FileInputStream;
11
19
  import java.net.HttpURLConnection;
12
20
  import java.net.URL;
13
- import java.net.URLConnection;
14
21
  import java.nio.channels.FileChannel;
15
- import java.util.Objects;
22
+ import java.security.MessageDigest;
23
+ import java.util.ArrayList;
24
+ import java.util.List;
25
+ import java.util.concurrent.CompletableFuture;
26
+ import java.util.concurrent.ExecutorService;
27
+ import java.util.concurrent.Executors;
28
+ import java.util.concurrent.Future;
29
+ import java.util.concurrent.atomic.AtomicBoolean;
30
+ import java.util.concurrent.atomic.AtomicLong;
31
+ import org.brotli.dec.BrotliInputStream;
32
+ import org.json.JSONArray;
33
+ import org.json.JSONObject;
16
34
 
17
35
  public class DownloadService extends IntentService {
18
36
 
@@ -27,6 +45,8 @@ public class DownloadService extends IntentService {
27
45
  public static final String CHECKSUM = "checksum";
28
46
  public static final String NOTIFICATION = "service receiver";
29
47
  public static final String PERCENTDOWNLOAD = "percent receiver";
48
+ public static final String IS_MANIFEST = "is_manifest";
49
+ public static final String MANIFEST = "manifest";
30
50
  private static final String UPDATE_FILE = "update.dat";
31
51
 
32
52
  public DownloadService() {
@@ -53,7 +73,159 @@ public class DownloadService extends IntentService {
53
73
  String version = intent.getStringExtra(VERSION);
54
74
  String sessionKey = intent.getStringExtra(SESSIONKEY);
55
75
  String checksum = intent.getStringExtra(CHECKSUM);
76
+ String manifestString = intent.getStringExtra(MANIFEST);
56
77
 
78
+ Log.d("DownloadService", "onHandleIntent" + manifestString);
79
+ if (manifestString != null) {
80
+ handleManifestDownload(
81
+ id,
82
+ documentsDir,
83
+ dest,
84
+ version,
85
+ sessionKey,
86
+ manifestString
87
+ );
88
+ } else {
89
+ handleSingleFileDownload(
90
+ url,
91
+ id,
92
+ documentsDir,
93
+ dest,
94
+ version,
95
+ sessionKey,
96
+ checksum
97
+ );
98
+ }
99
+ }
100
+
101
+ private void handleManifestDownload(
102
+ String id,
103
+ String documentsDir,
104
+ String dest,
105
+ String version,
106
+ String sessionKey,
107
+ String manifestString
108
+ ) {
109
+ try {
110
+ Log.d("DownloadService", "handleManifestDownload");
111
+ JSONArray manifest = new JSONArray(manifestString);
112
+ File destFolder = new File(documentsDir, dest);
113
+ File cacheFolder = new File(
114
+ getApplicationContext().getCacheDir(),
115
+ "capgo_downloads"
116
+ );
117
+ // Ensure directories are created
118
+ if (!destFolder.exists() && !destFolder.mkdirs()) {
119
+ throw new IOException(
120
+ "Failed to create destination directory: " +
121
+ destFolder.getAbsolutePath()
122
+ );
123
+ }
124
+ if (!cacheFolder.exists() && !cacheFolder.mkdirs()) {
125
+ throw new IOException(
126
+ "Failed to create cache directory: " + cacheFolder.getAbsolutePath()
127
+ );
128
+ }
129
+
130
+ int totalFiles = manifest.length();
131
+ final AtomicLong completedFiles = new AtomicLong(0);
132
+ final AtomicBoolean hasError = new AtomicBoolean(false);
133
+
134
+ // Use more threads for I/O-bound operations
135
+ int threadCount = Math.min(
136
+ Runtime.getRuntime().availableProcessors() * 2,
137
+ 32
138
+ );
139
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount);
140
+ CompletableFuture<Void>[] futures = new CompletableFuture[totalFiles];
141
+
142
+ for (int i = 0; i < totalFiles; i++) {
143
+ JSONObject entry = manifest.getJSONObject(i);
144
+ String fileName = entry.getString("file_name");
145
+ String fileHash = entry.getString("file_hash");
146
+ String downloadUrl = entry.getString("download_url");
147
+
148
+ File targetFile = new File(destFolder, fileName);
149
+ File cacheFile = new File(
150
+ cacheFolder,
151
+ fileHash + "_" + new File(fileName).getName()
152
+ );
153
+
154
+ // Ensure parent directories of the target file exist
155
+ if (
156
+ !targetFile.getParentFile().exists() &&
157
+ !targetFile.getParentFile().mkdirs()
158
+ ) {
159
+ throw new IOException(
160
+ "Failed to create parent directory for: " +
161
+ targetFile.getAbsolutePath()
162
+ );
163
+ }
164
+
165
+ futures[i] = CompletableFuture.runAsync(
166
+ () -> {
167
+ try {
168
+ if (cacheFile.exists()) {
169
+ if (verifyChecksum(cacheFile, fileHash)) {
170
+ copyFile(cacheFile, targetFile);
171
+ Log.d("DownloadService", "already cached " + fileName);
172
+ } else {
173
+ cacheFile.delete();
174
+ downloadAndVerify(
175
+ downloadUrl,
176
+ targetFile,
177
+ cacheFile,
178
+ fileHash,
179
+ id
180
+ );
181
+ }
182
+ } else {
183
+ downloadAndVerify(
184
+ downloadUrl,
185
+ targetFile,
186
+ cacheFile,
187
+ fileHash,
188
+ id
189
+ );
190
+ }
191
+
192
+ long completed = completedFiles.incrementAndGet();
193
+ int percent = calcTotalPercent(completed, totalFiles);
194
+ notifyDownload(id, percent);
195
+ } catch (Exception e) {
196
+ Log.e("DownloadService", "Error processing file: " + fileName, e);
197
+ hasError.set(true);
198
+ }
199
+ },
200
+ executor
201
+ );
202
+ }
203
+
204
+ // Wait for all downloads to complete
205
+ CompletableFuture.allOf(futures).join();
206
+
207
+ executor.shutdown();
208
+
209
+ if (hasError.get()) {
210
+ throw new IOException("One or more files failed to download");
211
+ }
212
+
213
+ publishResults(dest, id, version, "", sessionKey, "", true);
214
+ } catch (Exception e) {
215
+ Log.e("DownloadService", "Error in handleManifestDownload", e);
216
+ publishResults("", id, version, "", sessionKey, e.getMessage(), true);
217
+ }
218
+ }
219
+
220
+ private void handleSingleFileDownload(
221
+ String url,
222
+ String id,
223
+ String documentsDir,
224
+ String dest,
225
+ String version,
226
+ String sessionKey,
227
+ String checksum
228
+ ) {
57
229
  File target = new File(documentsDir, dest);
58
230
  File infoFile = new File(documentsDir, UPDATE_FILE); // The file where the download progress (how much byte
59
231
  // downloaded) is stored
@@ -138,14 +310,22 @@ public class DownloadService extends IntentService {
138
310
  // Rename the temp file with the final name (dest)
139
311
  tempFile.renameTo(new File(documentsDir, dest));
140
312
  infoFile.delete();
141
- publishResults(dest, id, version, checksum, sessionKey, "");
313
+ publishResults(dest, id, version, checksum, sessionKey, "", false);
142
314
  } else {
143
315
  infoFile.delete();
144
316
  }
145
317
  httpConn.disconnect();
146
318
  } catch (OutOfMemoryError e) {
147
319
  e.printStackTrace();
148
- publishResults("", id, version, checksum, sessionKey, "low_mem_fail");
320
+ publishResults(
321
+ "",
322
+ id,
323
+ version,
324
+ checksum,
325
+ sessionKey,
326
+ "low_mem_fail",
327
+ false
328
+ );
149
329
  } catch (Exception e) {
150
330
  e.printStackTrace();
151
331
  publishResults(
@@ -154,7 +334,8 @@ public class DownloadService extends IntentService {
154
334
  version,
155
335
  checksum,
156
336
  sessionKey,
157
- e.getLocalizedMessage()
337
+ e.getLocalizedMessage(),
338
+ false
158
339
  );
159
340
  }
160
341
  }
@@ -186,7 +367,8 @@ public class DownloadService extends IntentService {
186
367
  String version,
187
368
  String checksum,
188
369
  String sessionKey,
189
- String error
370
+ String error,
371
+ boolean isManifest
190
372
  ) {
191
373
  Intent intent = new Intent(NOTIFICATION);
192
374
  intent.setPackage(getPackageName());
@@ -198,6 +380,143 @@ public class DownloadService extends IntentService {
198
380
  intent.putExtra(VERSION, version);
199
381
  intent.putExtra(SESSIONKEY, sessionKey);
200
382
  intent.putExtra(CHECKSUM, checksum);
383
+ intent.putExtra(IS_MANIFEST, isManifest);
201
384
  sendBroadcast(intent);
202
385
  }
386
+
387
+ // Helper methods
388
+
389
+ private void copyFile(File source, File dest) throws IOException {
390
+ try (
391
+ FileInputStream inStream = new FileInputStream(source);
392
+ FileOutputStream outStream = new FileOutputStream(dest);
393
+ FileChannel inChannel = inStream.getChannel();
394
+ FileChannel outChannel = outStream.getChannel()
395
+ ) {
396
+ inChannel.transferTo(0, inChannel.size(), outChannel);
397
+ }
398
+ }
399
+
400
+ private void downloadAndVerify(
401
+ String downloadUrl,
402
+ File targetFile,
403
+ File cacheFile,
404
+ String expectedHash,
405
+ String id
406
+ ) throws Exception {
407
+ Log.d("DownloadService", "downloadAndVerify " + downloadUrl);
408
+ URL url = new URL(downloadUrl);
409
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
410
+ connection.setRequestMethod("GET");
411
+
412
+ // Create a temporary file for the compressed data
413
+ File compressedFile = new File(
414
+ getApplicationContext().getCacheDir(),
415
+ "temp_" + targetFile.getName() + ".br"
416
+ );
417
+
418
+ try (
419
+ InputStream inputStream = connection.getInputStream();
420
+ FileOutputStream compressedFos = new FileOutputStream(compressedFile)
421
+ ) {
422
+ byte[] buffer = new byte[8192];
423
+ int bytesRead;
424
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
425
+ compressedFos.write(buffer, 0, bytesRead);
426
+ }
427
+ }
428
+
429
+ // Decompress the file
430
+ try (
431
+ FileInputStream fis = new FileInputStream(compressedFile);
432
+ BrotliInputStream brotliInputStream = new BrotliInputStream(fis);
433
+ FileOutputStream fos = new FileOutputStream(targetFile)
434
+ ) {
435
+ byte[] buffer = new byte[8192];
436
+ int len;
437
+ while ((len = brotliInputStream.read(buffer)) != -1) {
438
+ fos.write(buffer, 0, len);
439
+ }
440
+ }
441
+
442
+ // Delete the compressed file
443
+ compressedFile.delete();
444
+
445
+ // Verify checksum
446
+ String actualHash = calculateFileHash(targetFile);
447
+ if (actualHash.equals(expectedHash)) {
448
+ // Copy the downloaded file to cache if checksum is correct
449
+ copyFile(targetFile, cacheFile);
450
+ Log.d("DownloadService", "copied to cache " + targetFile.getName());
451
+ } else {
452
+ targetFile.delete();
453
+ throw new IOException(
454
+ "Checksum verification failed for " +
455
+ targetFile.getName() +
456
+ " " +
457
+ expectedHash +
458
+ " " +
459
+ actualHash
460
+ );
461
+ }
462
+ }
463
+
464
+ // Custom request for handling input stream
465
+ private class InputStreamVolleyRequest extends Request<byte[]> {
466
+
467
+ private final Response.Listener<byte[]> mListener;
468
+
469
+ public InputStreamVolleyRequest(
470
+ int method,
471
+ String mUrl,
472
+ Response.Listener<byte[]> listener,
473
+ Response.ErrorListener errorListener
474
+ ) {
475
+ super(method, mUrl, errorListener);
476
+ mListener = listener;
477
+ }
478
+
479
+ @Override
480
+ protected void deliverResponse(byte[] response) {
481
+ mListener.onResponse(response);
482
+ }
483
+
484
+ @Override
485
+ protected Response<byte[]> parseNetworkResponse(NetworkResponse response) {
486
+ byte[] responseData = response.data;
487
+ return Response.success(
488
+ responseData,
489
+ HttpHeaderParser.parseCacheHeaders(response)
490
+ );
491
+ }
492
+ }
493
+
494
+ private boolean verifyChecksum(File file, String expectedHash) {
495
+ try {
496
+ String actualHash = calculateFileHash(file);
497
+ return actualHash.equals(expectedHash);
498
+ } catch (Exception e) {
499
+ e.printStackTrace();
500
+ return false;
501
+ }
502
+ }
503
+
504
+ private String calculateFileHash(File file) throws Exception {
505
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
506
+ FileInputStream fis = new FileInputStream(file);
507
+ byte[] byteArray = new byte[1024];
508
+ int bytesCount = 0;
509
+
510
+ while ((bytesCount = fis.read(byteArray)) != -1) {
511
+ digest.update(byteArray, 0, bytesCount);
512
+ }
513
+ fis.close();
514
+
515
+ byte[] bytes = digest.digest();
516
+ StringBuilder sb = new StringBuilder();
517
+ for (byte aByte : bytes) {
518
+ sb.append(Integer.toString((aByte & 0xff) + 0x100, 16).substring(1));
519
+ }
520
+ return sb.toString();
521
+ }
203
522
  }
@@ -9,6 +9,7 @@ import SSZipArchive
9
9
  import Alamofire
10
10
  import zlib
11
11
  import CryptoKit
12
+ import Compression
12
13
 
13
14
  extension Collection {
14
15
  subscript(safe index: Index) -> Element? {
@@ -88,6 +89,13 @@ struct InfoObject: Codable {
88
89
  var channel: String?
89
90
  var defaultChannel: String?
90
91
  }
92
+
93
+ public struct ManifestEntry: Codable {
94
+ let file_name: String?
95
+ let file_hash: String?
96
+ let download_url: String?
97
+ }
98
+
91
99
  struct AppVersionDec: Decodable {
92
100
  let version: String?
93
101
  let checksum: String?
@@ -97,7 +105,9 @@ struct AppVersionDec: Decodable {
97
105
  let session_key: String?
98
106
  let major: Bool?
99
107
  let data: [String: String]?
108
+ let manifest: [ManifestEntry]?
100
109
  }
110
+
101
111
  public class AppVersion: NSObject {
102
112
  var version: String = ""
103
113
  var checksum: String = ""
@@ -107,6 +117,7 @@ public class AppVersion: NSObject {
107
117
  var sessionKey: String?
108
118
  var major: Bool?
109
119
  var data: [String: String]?
120
+ var manifest: [ManifestEntry]?
110
121
  }
111
122
 
112
123
  extension AppVersion {
@@ -246,6 +257,9 @@ extension CustomError: LocalizedError {
246
257
  private let NEXT_VERSION: String = "nextVersion"
247
258
  private var unzipPercent = 0
248
259
 
260
+ // Add this line to declare cacheFolder
261
+ private let cacheFolder: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("capgo_downloads")
262
+
249
263
  public let TAG: String = "✨ Capacitor-updater:"
250
264
  public let CAP_SERVER_PATH: String = "serverBasePath"
251
265
  public var versionBuild: String = ""
@@ -582,6 +596,9 @@ extension CustomError: LocalizedError {
582
596
  if let data = response.value?.data {
583
597
  latest.data = data
584
598
  }
599
+ if let manifest = response.value?.manifest {
600
+ latest.manifest = manifest
601
+ }
585
602
  case let .failure(error):
586
603
  print("\(self.TAG) Error getting Latest", response.value ?? "", error )
587
604
  latest.message = "Error getting Latest \(String(describing: response.value))"
@@ -685,6 +702,174 @@ extension CustomError: LocalizedError {
685
702
  }
686
703
  }
687
704
 
705
+ public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String) throws -> BundleInfo {
706
+ let id = self.randomString(length: 10)
707
+ print("\(self.TAG) downloadManifest start \(id)")
708
+ let destFolder = self.getBundleDirectory(id: id)
709
+
710
+ try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
711
+ try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
712
+
713
+ // Create and save BundleInfo before starting the download process
714
+ let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "")
715
+ self.saveBundleInfo(id: id, bundle: bundleInfo)
716
+
717
+ // Notify the start of the download process
718
+ self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
719
+
720
+ let dispatchGroup = DispatchGroup()
721
+ var downloadError: Error?
722
+
723
+ let totalFiles = manifest.count
724
+ var completedFiles = 0
725
+
726
+ for entry in manifest {
727
+ guard let fileName = entry.file_name,
728
+ let fileHash = entry.file_hash,
729
+ let downloadUrl = entry.download_url else {
730
+ continue
731
+ }
732
+
733
+ let fileNameWithoutPath = (fileName as NSString).lastPathComponent
734
+ let cacheFileName = "\(fileHash)_\(fileNameWithoutPath)"
735
+ let cacheFilePath = cacheFolder.appendingPathComponent(cacheFileName)
736
+ let destFilePath = destFolder.appendingPathComponent(fileName)
737
+
738
+ // Create necessary subdirectories in the destination folder
739
+ try FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
740
+
741
+ dispatchGroup.enter()
742
+
743
+ if FileManager.default.fileExists(atPath: cacheFilePath.path) {
744
+ // File exists in cache, copy to destination
745
+ do {
746
+ try FileManager.default.copyItem(at: cacheFilePath, to: destFilePath)
747
+ print("\(self.TAG) downloadManifest \(fileName) copy from cache \(id)")
748
+ completedFiles += 1
749
+ self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
750
+ dispatchGroup.leave()
751
+ } catch {
752
+ downloadError = error
753
+ print("\(self.TAG) downloadManifest \(fileName) cache error \(id): \(error)")
754
+ dispatchGroup.leave()
755
+ }
756
+ } else {
757
+ // File not in cache, download, decompress, and save to both cache and destination
758
+ AF.download(downloadUrl).responseData { response in
759
+ defer { dispatchGroup.leave() }
760
+
761
+ switch response.result {
762
+ case .success(let data):
763
+ do {
764
+ // Decompress the Brotli data
765
+ guard let decompressedData = self.decompressBrotli(data: data) else {
766
+ throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data"])
767
+ }
768
+ // Save decompressed data to cache
769
+ try decompressedData.write(to: cacheFilePath)
770
+ // Save decompressed data to destination
771
+ try decompressedData.write(to: destFilePath)
772
+
773
+ completedFiles += 1
774
+ self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
775
+ print("\(self.TAG) downloadManifest \(id) \(fileName) downloaded, decompressed, and cached")
776
+ } catch {
777
+ downloadError = error
778
+ print("\(self.TAG) downloadManifest \(id) \(fileName) error: \(error)")
779
+ }
780
+ case .failure(let error):
781
+ downloadError = error
782
+ print("\(self.TAG) downloadManifest \(id) \(fileName) download error: \(error)")
783
+ }
784
+ }
785
+ }
786
+ }
787
+
788
+ dispatchGroup.wait()
789
+
790
+ if let error = downloadError {
791
+ // Update bundle status to ERROR if download failed
792
+ let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.localizedString)
793
+ self.saveBundleInfo(id: id, bundle: errorBundle)
794
+ throw error
795
+ }
796
+
797
+ // Update bundle status to PENDING after successful download
798
+ let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.localizedString)
799
+ self.saveBundleInfo(id: id, bundle: updatedBundle)
800
+
801
+ print("\(self.TAG) downloadManifest done \(id)")
802
+ return updatedBundle
803
+ }
804
+
805
+ private func decompressBrotli(data: Data) -> Data? {
806
+ let outputBufferSize = 65536
807
+ var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
808
+ var decompressedData = Data()
809
+
810
+ let streamPointer = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
811
+ var status = compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_BROTLI)
812
+ guard status != COMPRESSION_STATUS_ERROR else {
813
+ print("\(self.TAG) Unable to initialize the decompression stream.")
814
+ return nil
815
+ }
816
+
817
+ defer {
818
+ compression_stream_destroy(streamPointer)
819
+ streamPointer.deallocate()
820
+ }
821
+
822
+ streamPointer.pointee.src_size = 0
823
+ streamPointer.pointee.dst_ptr = UnsafeMutablePointer<UInt8>(&outputBuffer)
824
+ streamPointer.pointee.dst_size = outputBufferSize
825
+
826
+ let input = data
827
+
828
+ while true {
829
+ if streamPointer.pointee.src_size == 0 {
830
+ streamPointer.pointee.src_size = input.count
831
+ input.withUnsafeBytes { rawBufferPointer in
832
+ if let baseAddress = rawBufferPointer.baseAddress {
833
+ streamPointer.pointee.src_ptr = baseAddress.assumingMemoryBound(to: UInt8.self)
834
+ } else {
835
+ print("\(self.TAG) Error: Unable to get base address of input data")
836
+ status = COMPRESSION_STATUS_ERROR
837
+ return
838
+ }
839
+ }
840
+ }
841
+
842
+ if status == COMPRESSION_STATUS_ERROR {
843
+ break
844
+ }
845
+
846
+ status = compression_stream_process(streamPointer, 0)
847
+
848
+ let have = outputBufferSize - streamPointer.pointee.dst_size
849
+ if have > 0 {
850
+ decompressedData.append(outputBuffer, count: have)
851
+ }
852
+
853
+ if status == COMPRESSION_STATUS_END {
854
+ break
855
+ } else if status == COMPRESSION_STATUS_ERROR {
856
+ print("\(self.TAG) Error during Brotli decompression")
857
+ return nil
858
+ }
859
+
860
+ if streamPointer.pointee.dst_size == 0 {
861
+ streamPointer.pointee.dst_ptr = UnsafeMutablePointer<UInt8>(&outputBuffer)
862
+ streamPointer.pointee.dst_size = outputBufferSize
863
+ }
864
+
865
+ if input.count == 0 {
866
+ break
867
+ }
868
+ }
869
+
870
+ return status == COMPRESSION_STATUS_END ? decompressedData : nil
871
+ }
872
+
688
873
  public func download(url: URL, version: String, sessionKey: String) throws -> BundleInfo {
689
874
  let id: String = self.randomString(length: 10)
690
875
  let semaphore = DispatchSemaphore(value: 0)
@@ -1170,7 +1355,7 @@ extension CustomError: LocalizedError {
1170
1355
  }
1171
1356
  semaphore.signal()
1172
1357
  }
1173
- semaphore.wait()
1358
+ semaphore.signal()
1174
1359
  }
1175
1360
  operationQueue.addOperation(operation)
1176
1361
 
@@ -14,8 +14,8 @@ import Version
14
14
  */
15
15
  @objc(CapacitorUpdaterPlugin)
16
16
  public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
17
- public let identifier = "CapacitorUpdaterPlugin"
18
- public let jsName = "CapacitorUpdater"
17
+ public let identifier = "CapacitorUpdaterPlugin"
18
+ public let jsName = "CapacitorUpdater"
19
19
  public let pluginMethods: [CAPPluginMethod] = [
20
20
  CAPPluginMethod(name: "download", returnType: CAPPluginReturnPromise),
21
21
  CAPPluginMethod(name: "setUpdateUrl", returnType: CAPPluginReturnPromise),
@@ -40,10 +40,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
40
40
  CAPPluginMethod(name: "getPluginVersion", returnType: CAPPluginReturnPromise),
41
41
  CAPPluginMethod(name: "next", returnType: CAPPluginReturnPromise),
42
42
  CAPPluginMethod(name: "isAutoUpdateEnabled", returnType: CAPPluginReturnPromise),
43
- CAPPluginMethod(name: "getBuiltinVersion", returnType: CAPPluginReturnPromise),
44
- ]
43
+ CAPPluginMethod(name: "getBuiltinVersion", returnType: CAPPluginReturnPromise)
44
+ ]
45
45
  public var implementation = CapacitorUpdater()
46
- private let PLUGIN_VERSION: String = "6.2.9"
46
+ private let PLUGIN_VERSION: String = "6.3.3"
47
47
  static let updateUrlDefault = "https://api.capgo.app/updates"
48
48
  static let statsUrlDefault = "https://api.capgo.app/stats"
49
49
  static let channelUrlDefault = "https://api.capgo.app/channel_self"
@@ -757,7 +757,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
757
757
  print("\(self.implementation.TAG) Failed to delete failed bundle: \(nextImpl!.toString())")
758
758
  }
759
759
  }
760
- nextImpl = try self.implementation.download(url: downloadUrl, version: latestVersionName, sessionKey: sessionKey)
760
+ if res.manifest != nil {
761
+ nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey)
762
+ } else {
763
+ nextImpl = try self.implementation.download(url: downloadUrl, version: latestVersionName, sessionKey: sessionKey)
764
+ }
761
765
  }
762
766
  guard let next = nextImpl else {
763
767
  print("\(self.implementation.TAG) Error downloading file")
@@ -772,7 +776,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
772
776
  if !self.implementation.hasOldPrivateKeyPropertyInConfig {
773
777
  res.checksum = try self.implementation.decryptChecksum(checksum: res.checksum, version: latestVersionName)
774
778
  }
775
- if res.checksum != "" && next.getChecksum() != res.checksum {
779
+ if res.checksum != "" && next.getChecksum() != res.checksum && res.manifest == nil {
776
780
  print("\(self.implementation.TAG) Error checksum", next.getChecksum(), res.checksum)
777
781
  self.implementation.sendStats(action: "checksum_fail", versionName: next.getVersionName())
778
782
  let id = next.getId()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "6.2.9",
3
+ "version": "6.3.3",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",
@@ -59,22 +59,22 @@
59
59
  "@capacitor/android": "^6.0.0",
60
60
  "@capacitor/cli": "^6.0.0",
61
61
  "@capacitor/core": "^6.0.0",
62
- "@capacitor/docgen": "^0.2.2",
62
+ "@capacitor/docgen": "^0.3.0",
63
63
  "@capacitor/ios": "^6.0.0",
64
64
  "@ionic/eslint-config": "^0.4.0",
65
65
  "@ionic/prettier-config": "^4.0.0",
66
66
  "@ionic/swiftlint-config": "^1.1.2",
67
- "@types/node": "^20.12.12",
67
+ "@types/node": "^22.7.5",
68
68
  "@typescript-eslint/eslint-plugin": "^7.11.0",
69
69
  "@typescript-eslint/parser": "^7.11.0",
70
70
  "eslint": "^8.57.0",
71
- "eslint-plugin-import": "^2.29.1",
72
- "prettier": "^3.2.5",
73
- "prettier-plugin-java": "^2.6.0",
74
- "rimraf": "^5.0.7",
75
- "rollup": "^4.18.0",
76
- "swiftlint": "^1.0.2",
77
- "typescript": "^5.4.5"
71
+ "eslint-plugin-import": "^2.31.0",
72
+ "prettier": "^3.3.3",
73
+ "prettier-plugin-java": "^2.6.4",
74
+ "rimraf": "^6.0.1",
75
+ "rollup": "^4.24.0",
76
+ "swiftlint": "^2.0.0",
77
+ "typescript": "^5.6.3"
78
78
  },
79
79
  "peerDependencies": {
80
80
  "@capacitor/core": "^6.0.0"