@capawesome/capacitor-live-update 6.0.8 → 6.2.0-dev.0a2a6a5.1722676620

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.
Files changed (24) hide show
  1. package/README.md +34 -14
  2. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +136 -19
  3. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdateConfig.java +26 -0
  4. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java +31 -9
  5. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePreferences.java +17 -0
  6. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/api/GetLatestBundleResponse.java +29 -0
  7. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/DownloadBundleOptions.java +11 -1
  8. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetBundleResult.java +2 -1
  9. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/ReadyResult.java +32 -0
  10. package/dist/docs.json +117 -9
  11. package/dist/esm/definitions.d.ts +62 -8
  12. package/dist/esm/definitions.js.map +1 -1
  13. package/dist/esm/web.d.ts +2 -2
  14. package/dist/esm/web.js.map +1 -1
  15. package/ios/Plugin/Classes/CustomError.swift +12 -0
  16. package/ios/Plugin/Classes/Options/DownloadBundleOptions.swift +7 -1
  17. package/ios/Plugin/Classes/Results/GetBundleResult.swift +1 -1
  18. package/ios/Plugin/Classes/Results/ReadyResult.swift +22 -0
  19. package/ios/Plugin/LiveUpdate.swift +145 -15
  20. package/ios/Plugin/LiveUpdateConfig.swift +2 -0
  21. package/ios/Plugin/LiveUpdatePlugin.swift +15 -4
  22. package/ios/Plugin/LiveUpdatePreferences.swift +14 -0
  23. package/ios/Plugin/Protocols/Api/GetLatestBundleResponse.swift +2 -0
  24. package/package.json +1 -1
package/README.md CHANGED
@@ -7,11 +7,13 @@ Capacitor plugin to update your app remotely in real-time.
7
7
  - 🔋 Supports **Android and iOS**
8
8
  - ⚡️ **Capacitor 6** support
9
9
  - 📦 **Bundle Management**: Download, set, and delete bundles.
10
- - ☁️ **Cloud Support**: Use the [Capawesome Cloud](https://capawesome.io/cloud) to manage your app updates.
10
+ - ☁️ **Cloud Support**: Use the [Capawesome Cloud](https://capawesome.io/cloud/live-updates/) to manage your app updates.
11
11
  - 📺 **Channel Support**: Set a channel for the app to manage different versions.
12
12
  - 🔄 **Auto Update**: Automatically download and set the latest bundle for the app.
13
13
  - 🛟 **Rollback**: Reset the app to the default bundle if an incompatible bundle has been set.
14
14
  - 🚀 **Rollout**: Gradually roll out new bundles to gather valuable feedback.
15
+ - 🔒 **Security**: Verify the authenticity and integrity of the bundle using a public key.
16
+ - 🌐 **Open Source**: Licensed under the MIT License.
15
17
 
16
18
  ## Installation
17
19
 
@@ -63,13 +65,15 @@ We recommend to declare [`CA92.1`](https://developer.apple.com/documentation/bun
63
65
  <docgen-config>
64
66
  <!--Update the source file JSDoc comments and rerun docgen to update the docs below-->
65
67
 
66
- | Prop | Type | Description | Default | Since |
67
- | ----------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----- |
68
- | **`appId`** | <code>string</code> | The app ID is used to identify the app when using [Capawesome Cloud](https://capawesome.io/cloud). | | 5.0.0 |
69
- | **`autoDeleteBundles`** | <code>boolean</code> | Whether or not to automatically delete unused bundles. When enabled, the plugin will automatically delete unused bundles after calling `ready()`. | <code>false</code> | 5.0.0 |
70
- | **`enabled`** | <code>boolean</code> | Whether or not the plugin is enabled. | <code>true</code> | 5.0.0 |
71
- | **`readyTimeout`** | <code>number</code> | The timeout in milliseconds to wait for the app to be ready before resetting to the default bundle. | <code>10000</code> | 5.0.0 |
72
- | **`resetOnUpdate`** | <code>boolean</code> | Whether or not the app should be reset to the default bundle during an update. | <code>true</code> | 5.0.0 |
68
+ | Prop | Type | Description | Default | Since |
69
+ | ----------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----- |
70
+ | **`appId`** | <code>string</code> | The app ID is used to identify the app when using [Capawesome Cloud](https://capawesome.io/cloud). | | 5.0.0 |
71
+ | **`autoDeleteBundles`** | <code>boolean</code> | Whether or not to automatically delete unused bundles. When enabled, the plugin will automatically delete unused bundles after calling `ready()`. | <code>false</code> | 5.0.0 |
72
+ | **`enabled`** | <code>boolean</code> | Whether or not the plugin is enabled. | <code>true</code> | 5.0.0 |
73
+ | **`location`** | <code>'us' \| 'eu'</code> | The location of the server to use when using [Capawesome Cloud](https://capawesome.io/cloud). | <code>'us'</code> | 6.2.0 |
74
+ | **`publicKey`** | <code>string</code> | The public key to verify the integrity of the bundle. The public key must be a PEM-encoded RSA public key. | | 6.1.0 |
75
+ | **`readyTimeout`** | <code>number</code> | The timeout in milliseconds to wait for the app to be ready before resetting to the default bundle. | <code>10000</code> | 5.0.0 |
76
+ | **`resetOnUpdate`** | <code>boolean</code> | Whether or not the app should be reset to the default bundle during an update. | <code>true</code> | 5.0.0 |
73
77
 
74
78
  ### Examples
75
79
 
@@ -82,6 +86,8 @@ In `capacitor.config.json`:
82
86
  "appId": '4100e356-e851-47fe-9d3c-b411eb325a99',
83
87
  "autoDeleteBundles": undefined,
84
88
  "enabled": undefined,
89
+ "location": 'eu',
90
+ "publicKey": '-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDodf1SD0OOn6hIlDuKBza0Ed0OqtwyVJwiyjmE9BJaZ7y8ZUfcF+SKmd0l2cDPM45XIg2tAFux5n29uoKyHwSt+6tCi5CJA5Z1/1eZruRRqABLonV77KS3HUtvOgqRLDnKSV89dYZkM++NwmzOPgIF422mvc+VukcVOBfc8/AHQIDAQAB-----END PUBLIC KEY-----',
85
91
  "readyTimeout": undefined,
86
92
  "resetOnUpdate": undefined
87
93
  }
@@ -102,6 +108,8 @@ const config: CapacitorConfig = {
102
108
  appId: '4100e356-e851-47fe-9d3c-b411eb325a99',
103
109
  autoDeleteBundles: undefined,
104
110
  enabled: undefined,
111
+ location: 'eu',
112
+ publicKey: '-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDodf1SD0OOn6hIlDuKBza0Ed0OqtwyVJwiyjmE9BJaZ7y8ZUfcF+SKmd0l2cDPM45XIg2tAFux5n29uoKyHwSt+6tCi5CJA5Z1/1eZruRRqABLonV77KS3HUtvOgqRLDnKSV89dYZkM++NwmzOPgIF422mvc+VukcVOBfc8/AHQIDAQAB-----END PUBLIC KEY-----',
105
113
  readyTimeout: undefined,
106
114
  resetOnUpdate: undefined,
107
115
  },
@@ -393,7 +401,7 @@ Only available on Android and iOS.
393
401
  ### ready()
394
402
 
395
403
  ```typescript
396
- ready() => Promise<void>
404
+ ready() => Promise<ReadyResult>
397
405
  ```
398
406
 
399
407
  Notify the plugin that the app is ready to use and no rollback is needed.
@@ -403,6 +411,8 @@ to prevent the app from being reset to the default bundle.
403
411
 
404
412
  Only available on Android and iOS.
405
413
 
414
+ **Returns:** <code>Promise&lt;<a href="#readyresult">ReadyResult</a>&gt;</code>
415
+
406
416
  **Since:** 5.0.0
407
417
 
408
418
  --------------------
@@ -530,10 +540,11 @@ Only available on Android and iOS.
530
540
 
531
541
  #### DownloadBundleOptions
532
542
 
533
- | Prop | Type | Description | Since |
534
- | -------------- | ------------------- | --------------------------------------------------------------------------------------------------------- | ----- |
535
- | **`url`** | <code>string</code> | The URL of the bundle to download. The bundle must be a ZIP file containing at least a `index.html` file. | 5.0.0 |
536
- | **`bundleId`** | <code>string</code> | The unique identifier of the bundle. | 5.0.0 |
543
+ | Prop | Type | Description | Since |
544
+ | -------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------- | ----- |
545
+ | **`bundleId`** | <code>string</code> | The unique identifier of the bundle. | 5.0.0 |
546
+ | **`checksum`** | <code>string</code> | The checksum of the bundle to verify the integrity of the ZIP file. Must be a SHA-256 hash in hexadecimal format. | 6.1.0 |
547
+ | **`url`** | <code>string</code> | The URL of the bundle to download. The bundle must be a ZIP file containing at least a `index.html` file. | 5.0.0 |
537
548
 
538
549
 
539
550
  #### GetBundleResult
@@ -585,6 +596,15 @@ Only available on Android and iOS.
585
596
  | **`versionName`** | <code>string</code> | The version name of the app. On **Android**, this is the `versionName` from the `android/app/build.gradle` file. On **iOS**, this is the `CFBundleShortVersionString` from the `Info.plist` file. | 5.0.0 |
586
597
 
587
598
 
599
+ #### ReadyResult
600
+
601
+ | Prop | Type | Description | Since |
602
+ | ---------------------- | --------------------------- | --------------------------------------------------------------------------------------- | ----- |
603
+ | **`previousBundleId`** | <code>string \| null</code> | The identifier of the previous bundle used. If `null`, the default bundle was used. | 7.0.0 |
604
+ | **`currentBundleId`** | <code>string \| null</code> | The identifier of the current bundle used. If `null`, the default bundle is being used. | 7.0.0 |
605
+ | **`rollback`** | <code>boolean</code> | Whether or not the app was reset to the default bundle. | 7.0.0 |
606
+
607
+
588
608
  #### SetBundleOptions
589
609
 
590
610
  | Prop | Type | Description | Since |
@@ -630,7 +650,7 @@ If you want to disable the plugin to test other parts of your app, you can set t
630
650
 
631
651
  Live updates are only supported for [binary-compatible changes](https://capawesome.io/cloud/live-updates/faq/#what-are-binary-compatible-changes) (e.g. HTML, CSS, JavaScript).
632
652
  If you change native code, such as adding a new plugin, you need to resubmit your app to the app stores.
633
- For this reason, you must be careful to limit live updates to compatible native versions of your app.
653
+ For this reason, you must be careful to [restrict live updates to compatible native versions](https://capawesome.io/blog/how-to-restrict-capacitor-live-updates-to-native-versions/) of your app.
634
654
 
635
655
  ## Changelog
636
656
 
@@ -6,8 +6,10 @@ import android.content.pm.PackageInfo;
6
6
  import android.content.pm.PackageManager;
7
7
  import android.os.Build;
8
8
  import android.os.Handler;
9
+ import android.util.Base64;
9
10
  import androidx.annotation.NonNull;
10
11
  import androidx.annotation.Nullable;
12
+ import com.getcapacitor.Bridge;
11
13
  import com.getcapacitor.Logger;
12
14
  import com.getcapacitor.plugin.WebView;
13
15
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.api.GetLatestBundleResponse;
@@ -23,12 +25,18 @@ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetCustomIdR
23
25
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetDeviceIdResult;
24
26
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetVersionCodeResult;
25
27
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetVersionNameResult;
28
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.ReadyResult;
26
29
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.SyncResult;
27
30
  import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.EmptyCallback;
28
31
  import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.NonEmptyCallback;
29
32
  import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.Result;
30
33
  import java.io.File;
31
34
  import java.io.IOException;
35
+ import java.security.KeyFactory;
36
+ import java.security.MessageDigest;
37
+ import java.security.PublicKey;
38
+ import java.security.Signature;
39
+ import java.security.spec.X509EncodedKeySpec;
32
40
  import java.util.Arrays;
33
41
  import java.util.UUID;
34
42
  import net.lingala.zip4j.ZipFile;
@@ -48,11 +56,11 @@ import org.json.JSONObject;
48
56
 
49
57
  public class LiveUpdate {
50
58
 
59
+ public static final String DEFAULT_WEB_ASSET_DIR = Bridge.DEFAULT_WEB_ASSET_DIR;
60
+
51
61
  @NonNull
52
62
  private final LiveUpdateConfig config;
53
63
 
54
- private final String defaultWebAssetDir;
55
-
56
64
  @NonNull
57
65
  private final LiveUpdatePlugin plugin;
58
66
 
@@ -65,9 +73,10 @@ public class LiveUpdate {
65
73
  private final String bundlesDirectory = "_capacitor_live_update_bundles";
66
74
  private final Handler rollbackHandler = new Handler();
67
75
 
76
+ private boolean rollbackPerformed = false;
77
+
68
78
  public LiveUpdate(@NonNull LiveUpdateConfig config, @NonNull LiveUpdatePlugin plugin) throws PackageManager.NameNotFoundException {
69
79
  this.config = config;
70
- this.defaultWebAssetDir = plugin.getBridge().DEFAULT_WEB_ASSET_DIR;
71
80
  this.plugin = plugin;
72
81
  this.preferences = new LiveUpdatePreferences(plugin.getContext());
73
82
  this.webViewSettingsEditor = plugin.getContext().getSharedPreferences(WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE).edit();
@@ -97,6 +106,7 @@ public class LiveUpdate {
97
106
 
98
107
  public void downloadBundle(@NonNull DownloadBundleOptions options, @NonNull EmptyCallback callback) throws Exception {
99
108
  String bundleId = options.getBundleId();
109
+ String checksum = options.getChecksum();
100
110
  String url = options.getUrl();
101
111
 
102
112
  // Check if the bundle already exists
@@ -107,14 +117,11 @@ public class LiveUpdate {
107
117
  }
108
118
 
109
119
  // Download the bundle
110
- downloadBundle(bundleId, url, callback);
120
+ downloadBundle(bundleId, checksum, null, url, callback);
111
121
  }
112
122
 
113
123
  public void getBundle(@NonNull NonEmptyCallback callback) {
114
124
  String bundleId = getCurrentBundleId();
115
- if (bundleId.equals(defaultWebAssetDir)) {
116
- bundleId = null;
117
- }
118
125
  GetBundleResult result = new GetBundleResult(bundleId);
119
126
  callback.success(result);
120
127
  }
@@ -155,12 +162,18 @@ public class LiveUpdate {
155
162
  callback.success(result);
156
163
  }
157
164
 
158
- public void ready() {
165
+ public void ready(@NonNull NonEmptyCallback callback) {
159
166
  Logger.debug(LiveUpdatePlugin.TAG, "App is ready.");
160
167
  stopRollbackTimer();
161
168
  if (config.getAutoDeleteBundles()) {
162
169
  deleteUnusedBundles();
163
170
  }
171
+ String currentBundleId = getCurrentBundleId();
172
+ String previousBundleId = getPreviousBundleId();
173
+ ReadyResult result = new ReadyResult(currentBundleId, previousBundleId, rollbackPerformed);
174
+ callback.success(result);
175
+ setPreviousBundleId(currentBundleId);
176
+ rollbackPerformed = false;
164
177
  }
165
178
 
166
179
  public void reload() {
@@ -215,6 +228,8 @@ public class LiveUpdate {
215
228
  @Override
216
229
  public void success(@NonNull GetLatestBundleResponse result) {
217
230
  String latestBundleId = result.getBundleId();
231
+ String checksum = result.getChecksum();
232
+ String signature = result.getSignature();
218
233
  String url = result.getUrl();
219
234
 
220
235
  // Check if the bundle already exists
@@ -233,6 +248,8 @@ public class LiveUpdate {
233
248
  // Download and unzip the bundle
234
249
  downloadBundle(
235
250
  latestBundleId,
251
+ checksum,
252
+ signature,
236
253
  url,
237
254
  new EmptyCallback() {
238
255
  @Override
@@ -290,6 +307,31 @@ public class LiveUpdate {
290
307
  return new File(plugin.getContext().getFilesDir(), bundlesDirectory + "/" + bundle);
291
308
  }
292
309
 
310
+ private byte[] getChecksumForFileAsBytes(@NonNull File file) throws Exception {
311
+ try {
312
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
313
+ BufferedSource source = Okio.buffer(Okio.source(file));
314
+ Buffer buffer = new Buffer();
315
+ for (long bytesRead; (bytesRead = source.read(buffer, 2048)) != -1;) {
316
+ digest.update(buffer.readByteArray());
317
+ }
318
+ source.close();
319
+ return digest.digest();
320
+ } catch (IOException exception) {
321
+ Logger.error(LiveUpdatePlugin.TAG, exception.getMessage(), exception);
322
+ throw new Exception(LiveUpdatePlugin.ERROR_CHECKSUM_CALCULATION_FAILED);
323
+ }
324
+ }
325
+
326
+ private String getChecksumForFileAsString(@NonNull File file) throws Exception {
327
+ byte[] checksumBytes = getChecksumForFileAsBytes(file);
328
+ StringBuilder checksum = new StringBuilder();
329
+ for (byte checksumByte : checksumBytes) {
330
+ checksum.append(Integer.toString((checksumByte & 0xff) + 0x100, 16).substring(1));
331
+ }
332
+ return checksum.toString();
333
+ }
334
+
293
335
  private void createBundlesDirectory() {
294
336
  File bundlesDirectory = buildBundlesDirectory();
295
337
  if (!bundlesDirectory.exists()) {
@@ -297,6 +339,19 @@ public class LiveUpdate {
297
339
  }
298
340
  }
299
341
 
342
+ private PublicKey createPublicKeyFromString(@NonNull String value) throws Exception {
343
+ try {
344
+ value = value.replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", "").replace("\n", "");
345
+ byte[] byteKey = Base64.decode(value, Base64.DEFAULT);
346
+ X509EncodedKeySpec X509publicKey = new X509EncodedKeySpec(byteKey);
347
+ KeyFactory keyFactory = KeyFactory.getInstance("RSA");
348
+ return keyFactory.generatePublic(X509publicKey);
349
+ } catch (Exception exception) {
350
+ Logger.error(LiveUpdatePlugin.TAG, exception.getMessage(), exception);
351
+ throw new Exception(LiveUpdatePlugin.ERROR_PUBLIC_KEY_INVALID);
352
+ }
353
+ }
354
+
300
355
  private void deleteBundle(@NonNull String bundleId) {
301
356
  File bundleDirectory = buildBundleDirectoryFor(bundleId);
302
357
  deleteFileRecursively(bundleDirectory);
@@ -320,7 +375,13 @@ public class LiveUpdate {
320
375
  }
321
376
  }
322
377
 
323
- private void downloadBundle(@NonNull String bundleId, @NonNull String url, @NonNull EmptyCallback callback) {
378
+ private void downloadBundle(
379
+ @NonNull String bundleId,
380
+ @Nullable String expectedChecksum,
381
+ @Nullable String signature,
382
+ @NonNull String url,
383
+ @NonNull EmptyCallback callback
384
+ ) {
324
385
  downloadFile(
325
386
  url,
326
387
  new NonEmptyCallback<File>() {
@@ -332,6 +393,28 @@ public class LiveUpdate {
332
393
  @Override
333
394
  public void success(@NonNull File result) {
334
395
  try {
396
+ // Verify the signature
397
+ String publicKey = config.getPublicKey();
398
+ if (publicKey != null) {
399
+ if (signature == null) {
400
+ throw new Exception(LiveUpdatePlugin.ERROR_SIGNATURE_MISSING);
401
+ }
402
+ PublicKey key = createPublicKeyFromString(publicKey);
403
+ // Verify the signature
404
+ boolean verified = verifySignatureForFile(result, signature, key);
405
+ if (!verified) {
406
+ throw new Exception(LiveUpdatePlugin.ERROR_SIGNATURE_VERIFICATION_FAILED);
407
+ }
408
+ }
409
+ // Verify the checksum
410
+ else if (expectedChecksum != null) {
411
+ // Calculate the checksum
412
+ String receivedChecksum = getChecksumForFileAsString(result);
413
+ if (!expectedChecksum.equals(receivedChecksum)) {
414
+ throw new Exception(LiveUpdatePlugin.ERROR_CHECKSUM_MISMATCH);
415
+ }
416
+ }
417
+
335
418
  // Add the bundle
336
419
  addBundle(bundleId, result);
337
420
 
@@ -395,9 +478,13 @@ public class LiveUpdate {
395
478
  @Nullable
396
479
  private void fetchLatestBundle(@NonNull NonEmptyCallback<GetLatestBundleResponse> callback)
397
480
  throws PackageManager.NameNotFoundException {
481
+ String host = "api.cloud.capawesome.io";
482
+ if (config.getLocation() != null && config.getLocation().equals("eu")) {
483
+ host = "api.cloud.capawesome.eu";
484
+ }
398
485
  HttpUrl url = new HttpUrl.Builder()
399
486
  .scheme("https")
400
- .host("api.cloud.capawesome.io")
487
+ .host(host)
401
488
  .addPathSegment("v1")
402
489
  .addPathSegment("apps")
403
490
  .addPathSegment(config.getAppId())
@@ -477,8 +564,8 @@ public class LiveUpdate {
477
564
  */
478
565
  private String getCurrentBundleId() {
479
566
  String currentPath = getCurrentCapacitorServerPath();
480
- if (currentPath.equals(defaultWebAssetDir)) {
481
- return defaultWebAssetDir;
567
+ if (currentPath.equals(DEFAULT_WEB_ASSET_DIR)) {
568
+ return DEFAULT_WEB_ASSET_DIR;
482
569
  }
483
570
  return new File(currentPath).getName();
484
571
  }
@@ -506,8 +593,8 @@ public class LiveUpdate {
506
593
  @NonNull
507
594
  private String getNextBundleId() {
508
595
  String nextPath = getNextCapacitorServerPath();
509
- if (nextPath.equals(defaultWebAssetDir)) {
510
- return defaultWebAssetDir;
596
+ if (nextPath.equals(DEFAULT_WEB_ASSET_DIR)) {
597
+ return DEFAULT_WEB_ASSET_DIR;
511
598
  }
512
599
  return new File(nextPath).getName();
513
600
  }
@@ -520,7 +607,12 @@ public class LiveUpdate {
520
607
  return plugin
521
608
  .getContext()
522
609
  .getSharedPreferences(WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE)
523
- .getString(WebView.CAP_SERVER_PATH, defaultWebAssetDir);
610
+ .getString(WebView.CAP_SERVER_PATH, DEFAULT_WEB_ASSET_DIR);
611
+ }
612
+
613
+ @Nullable
614
+ private String getPreviousBundleId() {
615
+ return preferences.getPreviousBundleId();
524
616
  }
525
617
 
526
618
  private boolean hasBundle(@NonNull String bundleId) {
@@ -535,7 +627,10 @@ public class LiveUpdate {
535
627
  }
536
628
 
537
629
  private void rollback() {
538
- if (getCurrentBundleId() == defaultWebAssetDir) {
630
+ rollbackPerformed = true;
631
+ String currentBundleId = getCurrentBundleId();
632
+ setPreviousBundleId(currentBundleId);
633
+ if (currentBundleId.equals(DEFAULT_WEB_ASSET_DIR)) {
539
634
  Logger.debug(LiveUpdatePlugin.TAG, "App is not ready. Default bundle is already in use.");
540
635
  return;
541
636
  }
@@ -575,7 +670,7 @@ public class LiveUpdate {
575
670
  }
576
671
 
577
672
  private void setCurrentCapacitorServerPath(@NonNull String path) {
578
- if (path.equals(defaultWebAssetDir)) {
673
+ if (path.equals(DEFAULT_WEB_ASSET_DIR)) {
579
674
  this.plugin.getBridge().setServerAssetPath(path);
580
675
  } else {
581
676
  this.plugin.getBridge().setServerBasePath(path);
@@ -584,7 +679,7 @@ public class LiveUpdate {
584
679
  }
585
680
 
586
681
  private void setCurrentCapacitorServerPathToDefaultWebAssetDir() {
587
- setCurrentCapacitorServerPath(defaultWebAssetDir);
682
+ setCurrentCapacitorServerPath(DEFAULT_WEB_ASSET_DIR);
588
683
  }
589
684
 
590
685
  private void setNextBundle(@NonNull String bundleId) {
@@ -598,7 +693,11 @@ public class LiveUpdate {
598
693
  }
599
694
 
600
695
  private void setNextCapacitorServerPathToDefaultWebAssetDir() {
601
- setNextCapacitorServerPath(defaultWebAssetDir);
696
+ setNextCapacitorServerPath(DEFAULT_WEB_ASSET_DIR);
697
+ }
698
+
699
+ private void setPreviousBundleId(@Nullable String bundleId) {
700
+ preferences.setPreviousBundleId(bundleId);
602
701
  }
603
702
 
604
703
  private void startRollbackTimer() {
@@ -617,6 +716,24 @@ public class LiveUpdate {
617
716
  return destination;
618
717
  }
619
718
 
719
+ private boolean verifySignatureForFile(@NonNull File file, @NonNull String signature, @NonNull PublicKey key) throws Exception {
720
+ try {
721
+ byte[] signatureBytes = Base64.decode(signature, Base64.DEFAULT);
722
+ Signature sig = Signature.getInstance("SHA256withRSA");
723
+ sig.initVerify(key);
724
+ BufferedSource source = Okio.buffer(Okio.source(file));
725
+ Buffer buffer = new Buffer();
726
+ for (long bytesRead; (bytesRead = source.read(buffer, 2048)) != -1;) {
727
+ sig.update(buffer.readByteArray());
728
+ }
729
+ source.close();
730
+ return sig.verify(signatureBytes);
731
+ } catch (Exception exception) {
732
+ Logger.error(LiveUpdatePlugin.TAG, exception.getMessage(), exception);
733
+ throw new Exception(LiveUpdatePlugin.ERROR_SIGNATURE_VERIFICATION_FAILED);
734
+ }
735
+ }
736
+
620
737
  private boolean wasUpdated() throws PackageManager.NameNotFoundException {
621
738
  int lastVersionCode = preferences.getLastVersionCode();
622
739
  int currentVersionCode = getPackageInfo().versionCode;
@@ -1,6 +1,7 @@
1
1
  package io.capawesome.capacitorjs.plugins.liveupdate;
2
2
 
3
3
  import androidx.annotation.Nullable;
4
+ import java.security.Key;
4
5
 
5
6
  public class LiveUpdateConfig {
6
7
 
@@ -9,6 +10,13 @@ public class LiveUpdateConfig {
9
10
 
10
11
  private boolean autoDeleteBundles = false;
11
12
  private boolean enabled = true;
13
+
14
+ @Nullable
15
+ private String location = null;
16
+
17
+ @Nullable
18
+ private String publicKey = null;
19
+
12
20
  private int readyTimeout = 10000;
13
21
  private boolean resetOnUpdate = true;
14
22
 
@@ -25,6 +33,16 @@ public class LiveUpdateConfig {
25
33
  return enabled;
26
34
  }
27
35
 
36
+ @Nullable
37
+ public String getLocation() {
38
+ return location;
39
+ }
40
+
41
+ @Nullable
42
+ public String getPublicKey() {
43
+ return publicKey;
44
+ }
45
+
28
46
  public int getReadyTimeout() {
29
47
  return readyTimeout;
30
48
  }
@@ -45,6 +63,14 @@ public class LiveUpdateConfig {
45
63
  this.enabled = enabled;
46
64
  }
47
65
 
66
+ public void setLocation(@Nullable String location) {
67
+ this.location = location;
68
+ }
69
+
70
+ public void setPublicKey(@Nullable String publicKey) {
71
+ this.publicKey = publicKey;
72
+ }
73
+
48
74
  public void setReadyTimeout(int readyTimeout) {
49
75
  this.readyTimeout = readyTimeout;
50
76
  }
@@ -19,16 +19,21 @@ import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.Result;
19
19
  public class LiveUpdatePlugin extends Plugin {
20
20
 
21
21
  public static final String TAG = "LiveUpdate";
22
- public static final String VERSION = "6.0.7";
22
+ public static final String VERSION = "6.2.0-dev.0a2a6a5.1722676620";
23
23
  public static final String SHARED_PREFERENCES_NAME = "CapawesomeLiveUpdate"; // DO NOT CHANGE
24
24
  public static final String ERROR_APP_ID_MISSING = "appId must be configured.";
25
25
  public static final String ERROR_BUNDLE_EXISTS = "bundle already exists.";
26
26
  public static final String ERROR_BUNDLE_ID_MISSING = "bundleId must be provided.";
27
27
  public static final String ERROR_BUNDLE_INDEX_HTML_MISSING = "The bundle does not contain an index.html file.";
28
28
  public static final String ERROR_BUNDLE_NOT_FOUND = "bundle not found.";
29
+ public static final String ERROR_CHECKSUM_CALCULATION_FAILED = "Failed to calculate checksum.";
30
+ public static final String ERROR_CHECKSUM_MISMATCH = "Checksum mismatch.";
29
31
  public static final String ERROR_CUSTOM_ID_MISSING = "customId must be provided.";
30
32
  public static final String ERROR_DOWNLOAD_FAILED = "Bundle could not be downloaded.";
31
33
  public static final String ERROR_URL_MISSING = "url must be provided.";
34
+ public static final String ERROR_SIGNATURE_VERIFICATION_FAILED = "Signature verification failed.";
35
+ public static final String ERROR_PUBLIC_KEY_INVALID = "Invalid public key.";
36
+ public static final String ERROR_SIGNATURE_MISSING = "Bundle does not contain a signature.";
32
37
 
33
38
  @Nullable
34
39
  private LiveUpdateConfig config;
@@ -78,18 +83,19 @@ public class LiveUpdatePlugin extends Plugin {
78
83
  @PluginMethod
79
84
  public void downloadBundle(PluginCall call) {
80
85
  try {
81
- String url = call.getString("url");
82
- if (url == null) {
83
- call.reject(ERROR_URL_MISSING);
84
- return;
85
- }
86
86
  String bundleId = call.getString("bundleId");
87
87
  if (bundleId == null) {
88
88
  call.reject(ERROR_BUNDLE_ID_MISSING);
89
89
  return;
90
90
  }
91
+ String checksum = call.getString("checksum");
92
+ String url = call.getString("url");
93
+ if (url == null) {
94
+ call.reject(ERROR_URL_MISSING);
95
+ return;
96
+ }
91
97
 
92
- DownloadBundleOptions options = new DownloadBundleOptions(bundleId, url);
98
+ DownloadBundleOptions options = new DownloadBundleOptions(bundleId, checksum, url);
93
99
  EmptyCallback callback = new EmptyCallback() {
94
100
  @Override
95
101
  public void success() {
@@ -274,8 +280,20 @@ public class LiveUpdatePlugin extends Plugin {
274
280
  @PluginMethod
275
281
  public void ready(PluginCall call) {
276
282
  try {
277
- implementation.ready();
278
- call.resolve();
283
+ NonEmptyCallback<Result> callback = new NonEmptyCallback<>() {
284
+ @Override
285
+ public void success(Result result) {
286
+ call.resolve(result.toJSObject());
287
+ }
288
+
289
+ @Override
290
+ public void error(Exception exception) {
291
+ Logger.error(TAG, exception.getMessage(), exception);
292
+ call.reject(exception.getMessage());
293
+ }
294
+ };
295
+
296
+ implementation.ready(callback);
279
297
  } catch (Exception exception) {
280
298
  Logger.error(TAG, exception.getMessage(), exception);
281
299
  call.reject(exception.getMessage());
@@ -427,6 +445,10 @@ public class LiveUpdatePlugin extends Plugin {
427
445
  config.setAutoDeleteBundles(autoDeleteBundles);
428
446
  boolean enabled = getConfig().getBoolean("enabled", config.getEnabled());
429
447
  config.setEnabled(enabled);
448
+ String location = getConfig().getString("location", config.getLocation());
449
+ config.setLocation(location);
450
+ String publicKey = getConfig().getString("publicKey", config.getPublicKey());
451
+ config.setPublicKey(publicKey);
430
452
  int readyTimeout = getConfig().getInt("readyTimeout", config.getReadyTimeout());
431
453
  config.setReadyTimeout(readyTimeout);
432
454
  boolean resetOnUpdate = getConfig().getBoolean("resetOnUpdate", config.getResetOnUpdate());
@@ -19,6 +19,7 @@ public class LiveUpdatePreferences {
19
19
  private final String deviceIdKey = "deviceId"; // DO NOT CHANGE
20
20
  private final String customIdKey = "customId"; // DO NOT CHANGE
21
21
  private final String lastVersionCodeKey = "lastVersionCode"; // DO NOT CHANGE
22
+ private final String previousBundleIdKey = "previousBundleId"; // DO NOT CHANGE
22
23
 
23
24
  public LiveUpdatePreferences(@NonNull Context context) {
24
25
  this.context = context;
@@ -52,6 +53,13 @@ public class LiveUpdatePreferences {
52
53
  return context.getSharedPreferences(LiveUpdatePlugin.SHARED_PREFERENCES_NAME, Activity.MODE_PRIVATE).getInt(lastVersionCodeKey, 0);
53
54
  }
54
55
 
56
+ @Nullable
57
+ public String getPreviousBundleId() {
58
+ return context
59
+ .getSharedPreferences(LiveUpdatePlugin.SHARED_PREFERENCES_NAME, Activity.MODE_PRIVATE)
60
+ .getString(previousBundleIdKey, null);
61
+ }
62
+
55
63
  public void setChannel(@Nullable String channel) {
56
64
  if (channel == null) {
57
65
  settingsEditor.remove(channelKey);
@@ -83,4 +91,13 @@ public class LiveUpdatePreferences {
83
91
  settingsEditor.putInt(lastVersionCodeKey, lastVersionCode);
84
92
  settingsEditor.apply();
85
93
  }
94
+
95
+ public void setPreviousBundleId(@Nullable String bundleId) {
96
+ if (bundleId == null) {
97
+ settingsEditor.remove(previousBundleIdKey);
98
+ } else {
99
+ settingsEditor.putString(previousBundleIdKey, bundleId);
100
+ }
101
+ settingsEditor.apply();
102
+ }
86
103
  }
@@ -6,10 +6,29 @@ import org.json.JSONObject;
6
6
  public class GetLatestBundleResponse {
7
7
 
8
8
  private String bundleId;
9
+
10
+ @Nullable
11
+ private String checksum;
12
+
13
+ @Nullable
14
+ private String signature;
15
+
9
16
  private String url;
10
17
 
11
18
  public GetLatestBundleResponse(JSONObject responseJson) {
12
19
  this.bundleId = responseJson.optString("bundleId");
20
+ String checksum = responseJson.optString("checksum", "null");
21
+ if (checksum.equals("null")) {
22
+ this.checksum = null;
23
+ } else {
24
+ this.checksum = checksum;
25
+ }
26
+ String signature = responseJson.optString("signature", "null");
27
+ if (signature.equals("null")) {
28
+ this.signature = null;
29
+ } else {
30
+ this.signature = signature;
31
+ }
13
32
  this.url = responseJson.optString("url");
14
33
  }
15
34
 
@@ -17,6 +36,16 @@ public class GetLatestBundleResponse {
17
36
  return bundleId;
18
37
  }
19
38
 
39
+ @Nullable
40
+ public String getChecksum() {
41
+ return checksum;
42
+ }
43
+
44
+ @Nullable
45
+ public String getSignature() {
46
+ return signature;
47
+ }
48
+
20
49
  public String getUrl() {
21
50
  return url;
22
51
  }