@capawesome/capacitor-live-update 8.2.3 → 8.3.0-dev.f9cc1d15.1783929784

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 (22) hide show
  1. package/CapawesomeCapacitorLiveUpdate.podspec +16 -1
  2. package/Package.swift +22 -6
  3. package/README.md +213 -84
  4. package/android/build.gradle +17 -0
  5. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +69 -20
  6. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java +30 -1
  7. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/api/GetLatestBundleResponse.java +9 -0
  8. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/FetchLatestBundleOptions.java +15 -0
  9. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/FetchLatestBundleResult.java +38 -0
  10. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/providers/ionic/LiveUpdateIonicManager.kt +169 -0
  11. package/dist/docs.json +14 -2
  12. package/dist/esm/definitions.d.ts +17 -2
  13. package/dist/esm/definitions.js.map +1 -1
  14. package/ios/Plugin/Classes/Options/FetchLatestBundleOptions.swift +12 -0
  15. package/ios/Plugin/Classes/Results/FetchLatestBundleResult.swift +30 -1
  16. package/ios/Plugin/Enums/CustomError.swift +12 -0
  17. package/ios/Plugin/LiveUpdate.swift +23 -9
  18. package/ios/Plugin/LiveUpdatePlugin.swift +2 -2
  19. package/ios/Plugin/Protocols/Api/GetLatestBundleResponse.swift +1 -0
  20. package/ios/Plugin/Providers/Ionic/LiveUpdateIonicManager.swift +108 -0
  21. package/ios/Plugin/Providers/Ionic/LiveUpdatePlugin+LiveUpdateProvider.swift +15 -0
  22. package/package.json +11 -5
@@ -1,6 +1,7 @@
1
1
  package io.capawesome.capacitorjs.plugins.liveupdate;
2
2
 
3
3
  import android.app.Activity;
4
+ import android.content.Context;
4
5
  import android.content.SharedPreferences;
5
6
  import android.content.pm.PackageInfo;
6
7
  import android.content.pm.PackageManager;
@@ -87,12 +88,15 @@ public class LiveUpdate {
87
88
  @NonNull
88
89
  private final LiveUpdateConfig config;
89
90
 
91
+ @NonNull
92
+ private final Context context;
93
+
90
94
  private final String defaultWebAssetDir = Bridge.DEFAULT_WEB_ASSET_DIR;
91
95
 
92
96
  @NonNull
93
97
  private final LiveUpdateHttpClient httpClient;
94
98
 
95
- @NonNull
99
+ @Nullable
96
100
  private final LiveUpdatePlugin plugin;
97
101
 
98
102
  @NonNull
@@ -111,11 +115,26 @@ public class LiveUpdate {
111
115
  private boolean syncInProgress = false;
112
116
 
113
117
  public LiveUpdate(@NonNull LiveUpdateConfig config, @NonNull LiveUpdatePlugin plugin) throws PackageManager.NameNotFoundException {
118
+ this(config, plugin.getContext(), plugin);
119
+ }
120
+
121
+ /**
122
+ * Creates a headless instance without a Capacitor plugin, e.g. for Ionic Portals hosts
123
+ * that construct a provider manager directly. WebView-related operations (server path
124
+ * changes, reload) and plugin events are skipped in this mode.
125
+ */
126
+ public LiveUpdate(@NonNull LiveUpdateConfig config, @NonNull Context context) throws PackageManager.NameNotFoundException {
127
+ this(config, context, null);
128
+ }
129
+
130
+ private LiveUpdate(@NonNull LiveUpdateConfig config, @NonNull Context context, @Nullable LiveUpdatePlugin plugin)
131
+ throws PackageManager.NameNotFoundException {
114
132
  this.config = config;
133
+ this.context = context;
115
134
  this.httpClient = new LiveUpdateHttpClient(config);
116
135
  this.plugin = plugin;
117
- this.preferences = new LiveUpdatePreferences(plugin.getContext());
118
- this.webViewSettingsEditor = plugin.getContext().getSharedPreferences(WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE).edit();
136
+ this.preferences = new LiveUpdatePreferences(context);
137
+ this.webViewSettingsEditor = context.getSharedPreferences(WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE).edit();
119
138
 
120
139
  // Check version and reset config if version changed
121
140
  checkAndResetConfigIfVersionChanged();
@@ -232,6 +251,7 @@ public class LiveUpdate {
232
251
  public void success(@Nullable GetLatestBundleResponse response) {
233
252
  ArtifactType artifactType = response == null ? null : response.getArtifactType();
234
253
  String bundleId = response == null ? null : response.getBundleId();
254
+ String channel = response == null ? null : response.getChannelName();
235
255
  String checksum = response == null ? null : response.getChecksum();
236
256
  JSONObject customProperties = response == null ? null : response.getCustomProperties();
237
257
  String downloadUrl = response == null ? null : response.getUrl();
@@ -239,6 +259,7 @@ public class LiveUpdate {
239
259
  FetchLatestBundleResult result = new FetchLatestBundleResult(
240
260
  artifactType,
241
261
  bundleId,
262
+ channel,
242
263
  checksum,
243
264
  customProperties,
244
265
  downloadUrl,
@@ -298,6 +319,17 @@ public class LiveUpdate {
298
319
  callback.success(result);
299
320
  }
300
321
 
322
+ /**
323
+ * @return The on-disk directory of a downloaded bundle, or `null` if the bundle does not exist.
324
+ */
325
+ @Nullable
326
+ public File getBundleDirectory(@NonNull String bundleId) {
327
+ if (!hasBundleById(bundleId)) {
328
+ return null;
329
+ }
330
+ return buildBundleDirectoryFor(bundleId);
331
+ }
332
+
301
333
  public void getCustomId(@NonNull NonEmptyCallback callback) {
302
334
  String customId = preferences.getCustomId();
303
335
  GetCustomIdResult result = new GetCustomIdResult(customId);
@@ -547,21 +579,21 @@ public class LiveUpdate {
547
579
  }
548
580
 
549
581
  private File buildBundlesDirectory() {
550
- return new File(plugin.getContext().getFilesDir(), bundlesDirectory);
582
+ return new File(context.getFilesDir(), bundlesDirectory);
551
583
  }
552
584
 
553
585
  private File buildBundleDirectoryFor(@NonNull String bundleId) {
554
- return new File(plugin.getContext().getFilesDir(), bundlesDirectory + "/" + bundleId);
586
+ return new File(context.getFilesDir(), bundlesDirectory + "/" + bundleId);
555
587
  }
556
588
 
557
589
  private File buildTemporaryDirectory() {
558
590
  String fileName = UUID.randomUUID().toString();
559
- return new File(plugin.getContext().getCacheDir(), fileName);
591
+ return new File(context.getCacheDir(), fileName);
560
592
  }
561
593
 
562
594
  private File buildTemporaryZipFile() {
563
595
  String fileName = UUID.randomUUID().toString() + ".zip";
564
- return new File(plugin.getContext().getCacheDir(), fileName);
596
+ return new File(context.getCacheDir(), fileName);
565
597
  }
566
598
 
567
599
  private void copyCurrentBundleFile(@NonNull ManifestItem fileToCopy, @NonNull File destinationDirectory) throws IOException {
@@ -569,7 +601,7 @@ public class LiveUpdate {
569
601
  String currentBundleId = getCurrentBundleId();
570
602
  if (currentBundleId == null) {
571
603
  // Create the source input stream
572
- AssetManager assets = plugin.getContext().getAssets();
604
+ AssetManager assets = context.getAssets();
573
605
  InputStream inputStream = assets.open(defaultWebAssetDir + "/" + href);
574
606
  // Create the destination file
575
607
  File destination = new File(destinationDirectory, href);
@@ -975,13 +1007,17 @@ public class LiveUpdate {
975
1007
  @NonNull NonEmptyCallback<GetLatestBundleResponse> callback
976
1008
  ) {
977
1009
  try {
1010
+ String appId = options.getAppId() == null ? getAppId() : options.getAppId();
1011
+ if (appId == null || appId.isEmpty()) {
1012
+ throw new Exception(LiveUpdatePlugin.ERROR_APP_ID_MISSING);
1013
+ }
978
1014
  String channel = options.getChannel() == null ? getChannel() : options.getChannel();
979
1015
  String url = new HttpUrl.Builder()
980
1016
  .scheme("https")
981
1017
  .host(config.getServerDomain())
982
1018
  .addPathSegment("v1")
983
1019
  .addPathSegment("apps")
984
- .addPathSegment(getAppId())
1020
+ .addPathSegment(appId)
985
1021
  .addPathSegment("bundles")
986
1022
  .addPathSegment("latest")
987
1023
  .addQueryParameter("appVersionCode", getVersionCodeAsString())
@@ -1095,14 +1131,11 @@ public class LiveUpdate {
1095
1131
 
1096
1132
  @Nullable
1097
1133
  private String getNativeChannel() {
1098
- int resId = plugin
1099
- .getContext()
1100
- .getResources()
1101
- .getIdentifier("capawesome_live_update_default_channel", "string", plugin.getContext().getPackageName());
1134
+ int resId = context.getResources().getIdentifier("capawesome_live_update_default_channel", "string", context.getPackageName());
1102
1135
  if (resId == 0) {
1103
1136
  return null;
1104
1137
  }
1105
- return plugin.getContext().getResources().getString(resId);
1138
+ return context.getResources().getString(resId);
1106
1139
  }
1107
1140
 
1108
1141
  /**
@@ -1121,6 +1154,10 @@ public class LiveUpdate {
1121
1154
  * @return The absolute path to the current bundle directory (`public` for the built-in bundle).
1122
1155
  */
1123
1156
  private String getCurrentCapacitorServerPath() {
1157
+ if (plugin == null) {
1158
+ // Headless mode: there is no WebView, so the default bundle is always current.
1159
+ return defaultWebAssetDir;
1160
+ }
1124
1161
  return plugin.getBridge().getServerBasePath();
1125
1162
  }
1126
1163
 
@@ -1151,8 +1188,7 @@ public class LiveUpdate {
1151
1188
  */
1152
1189
  @NonNull
1153
1190
  private String getNextCapacitorServerPath() {
1154
- String path = plugin
1155
- .getContext()
1191
+ String path = context
1156
1192
  .getSharedPreferences(WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE)
1157
1193
  .getString(WebView.CAP_SERVER_PATH, defaultWebAssetDir);
1158
1194
  // Empty path means default path
@@ -1197,7 +1233,7 @@ public class LiveUpdate {
1197
1233
  private Manifest loadCurrentManifest() throws Exception {
1198
1234
  String currentBundleId = getCurrentBundleId();
1199
1235
  if (currentBundleId == null) {
1200
- AssetManager assets = plugin.getContext().getAssets();
1236
+ AssetManager assets = context.getAssets();
1201
1237
  boolean manifestFileExists = Arrays.asList(assets.list(defaultWebAssetDir)).contains(manifestFileName);
1202
1238
  if (manifestFileExists) {
1203
1239
  InputStream inputStream = assets.open(defaultWebAssetDir + "/" + manifestFileName);
@@ -1229,6 +1265,9 @@ public class LiveUpdate {
1229
1265
  }
1230
1266
 
1231
1267
  private void notifyDownloadBundleProgressListeners(@NonNull final DownloadBundleProgressEvent event) {
1268
+ if (plugin == null) {
1269
+ return;
1270
+ }
1232
1271
  plugin.notifyDownloadBundleProgressListeners(event);
1233
1272
  }
1234
1273
 
@@ -1315,6 +1354,10 @@ public class LiveUpdate {
1315
1354
  }
1316
1355
 
1317
1356
  private void setCurrentCapacitorServerPath(@NonNull String path) {
1357
+ if (plugin == null) {
1358
+ // Headless mode: there is no WebView to point at the new path.
1359
+ return;
1360
+ }
1318
1361
  if (path.equals(defaultWebAssetDir)) {
1319
1362
  this.plugin.getBridge().setServerAssetPath(path);
1320
1363
  } else {
@@ -1341,11 +1384,17 @@ public class LiveUpdate {
1341
1384
  }
1342
1385
 
1343
1386
  private void notifyNextBundleSetListeners(@Nullable String bundleId) {
1387
+ if (plugin == null) {
1388
+ return;
1389
+ }
1344
1390
  NextBundleSetEvent event = new NextBundleSetEvent(bundleId);
1345
1391
  plugin.notifyNextBundleSetListeners(event);
1346
1392
  }
1347
1393
 
1348
1394
  private void notifyReloadedListeners() {
1395
+ if (plugin == null) {
1396
+ return;
1397
+ }
1349
1398
  plugin.notifyReloadedListeners();
1350
1399
  }
1351
1400
 
@@ -1498,11 +1547,11 @@ public class LiveUpdate {
1498
1547
  }
1499
1548
 
1500
1549
  private PackageInfo getPackageInfo() throws PackageManager.NameNotFoundException {
1501
- String packageName = this.plugin.getContext().getPackageName();
1550
+ String packageName = this.context.getPackageName();
1502
1551
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
1503
- return this.plugin.getContext().getPackageManager().getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0));
1552
+ return this.context.getPackageManager().getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0));
1504
1553
  } else {
1505
- return this.plugin.getContext().getPackageManager().getPackageInfo(packageName, 0);
1554
+ return this.context.getPackageManager().getPackageInfo(packageName, 0);
1506
1555
  }
1507
1556
  }
1508
1557
  }
@@ -1,5 +1,6 @@
1
1
  package io.capawesome.capacitorjs.plugins.liveupdate;
2
2
 
3
+ import android.content.Context;
3
4
  import android.webkit.WebView;
4
5
  import androidx.annotation.NonNull;
5
6
  import androidx.annotation.Nullable;
@@ -32,14 +33,19 @@ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.IsSyncingRes
32
33
  import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.EmptyCallback;
33
34
  import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.NonEmptyCallback;
34
35
  import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.Result;
36
+ import io.capawesome.capacitorjs.plugins.liveupdate.providers.ionic.LiveUpdateIonicManager;
37
+ import io.ionic.liveupdateprovider.ProviderError;
38
+ import io.ionic.liveupdateprovider.ProviderManager;
39
+ import java.util.Map;
35
40
 
36
41
  @CapacitorPlugin(name = "LiveUpdate")
37
42
  public class LiveUpdatePlugin extends Plugin {
38
43
 
39
44
  public static final String TAG = "LiveUpdate";
40
- public static final String VERSION = "8.2.3";
45
+ public static final String VERSION = "8.3.0";
41
46
  public static final String SHARED_PREFERENCES_NAME = "CapawesomeLiveUpdate"; // DO NOT CHANGE
42
47
  public static final String ERROR_APP_ID_MISSING = "appId must be configured.";
48
+ public static final String ERROR_BUNDLE_DIRECTORY_NOT_FOUND = "Bundle directory could not be resolved.";
43
49
  public static final String ERROR_BUNDLE_EXISTS = "bundle already exists.";
44
50
  public static final String ERROR_BUNDLE_ID_MISSING = "bundleId must be provided.";
45
51
  public static final String ERROR_BUNDLE_INDEX_HTML_MISSING = "The bundle does not contain an index.html file.";
@@ -48,7 +54,10 @@ public class LiveUpdatePlugin extends Plugin {
48
54
  public static final String ERROR_CHECKSUM_MISMATCH = "Checksum mismatch.";
49
55
  public static final String ERROR_CUSTOM_ID_MISSING = "customId must be provided.";
50
56
  public static final String ERROR_DOWNLOAD_FAILED = "Bundle could not be downloaded.";
57
+ public static final String ERROR_DOWNLOAD_URL_MISSING = "Bundle does not have a valid download URL.";
51
58
  public static final String ERROR_HTTP_TIMEOUT = "Request timed out.";
59
+ public static final String ERROR_MANAGER_KEY_MISSING = "managerKey must be provided.";
60
+ public static final String ERROR_PLUGIN_NOT_INITIALIZED = "Plugin is not initialized.";
52
61
  public static final String ERROR_URL_MISSING = "url must be provided.";
53
62
  public static final String ERROR_SIGNATURE_VERIFICATION_FAILED = "Signature verification failed.";
54
63
  public static final String ERROR_PUBLIC_KEY_INVALID = "Invalid public key.";
@@ -76,6 +85,26 @@ public class LiveUpdatePlugin extends Plugin {
76
85
  }
77
86
  }
78
87
 
88
+ /**
89
+ * Creates a manager for the Ionic Live Update Provider SDK. Invoked natively by
90
+ * Federated Capacitor after resolving this plugin by its Capacitor plugin name.
91
+ *
92
+ * This class deliberately does NOT implement the SDK's `LiveUpdateProvider` interface:
93
+ * superinterfaces are resolved eagerly at class-definition time, which would crash the
94
+ * plugin when the compileOnly SDK is absent at runtime. Instead, this method matches the
95
+ * interface's exact signature and Federated Capacitor invokes it via reflection when the
96
+ * cast to `LiveUpdateProvider` fails. Method references are resolved lazily, so this
97
+ * class loads fine without the SDK. Do NOT change the signature.
98
+ */
99
+ @NonNull
100
+ public ProviderManager createManager(@NonNull Context context, @NonNull Map<String, ?> configuration)
101
+ throws ProviderError.InvalidConfiguration {
102
+ if (implementation == null) {
103
+ throw new ProviderError.InvalidConfiguration(ERROR_PLUGIN_NOT_INITIALIZED, null);
104
+ }
105
+ return new LiveUpdateIonicManager(context, configuration, implementation);
106
+ }
107
+
79
108
  @Override
80
109
  protected void handleOnResume() {
81
110
  super.handleOnResume();
@@ -16,6 +16,9 @@ public class GetLatestBundleResponse {
16
16
  @NonNull
17
17
  private String bundleId;
18
18
 
19
+ @Nullable
20
+ private String channelName;
21
+
19
22
  @Nullable
20
23
  private String checksum;
21
24
 
@@ -36,6 +39,7 @@ public class GetLatestBundleResponse {
36
39
  this.artifactType = ArtifactType.ZIP;
37
40
  }
38
41
  this.bundleId = responseJson.optString("bundleId");
42
+ this.channelName = responseJson.isNull("channelName") ? null : responseJson.optString("channelName");
39
43
  this.checksum = responseJson.isNull("checksum") ? null : responseJson.optString("checksum");
40
44
  this.customProperties = responseJson.optJSONObject("customProperties");
41
45
  this.signature = responseJson.isNull("signature") ? null : responseJson.optString("signature");
@@ -52,6 +56,11 @@ public class GetLatestBundleResponse {
52
56
  return bundleId;
53
57
  }
54
58
 
59
+ @Nullable
60
+ public String getChannelName() {
61
+ return channelName;
62
+ }
63
+
55
64
  @Nullable
56
65
  public JSONObject getCustomProperties() {
57
66
  return customProperties;
@@ -6,17 +6,32 @@ import com.getcapacitor.PluginCall;
6
6
 
7
7
  public class FetchLatestBundleOptions {
8
8
 
9
+ @Nullable
10
+ private final String appId;
11
+
9
12
  @Nullable
10
13
  private final String channel;
11
14
 
12
15
  public FetchLatestBundleOptions(@NonNull PluginCall call) {
16
+ this.appId = null;
13
17
  this.channel = call.getString("channel", null);
14
18
  }
15
19
 
16
20
  public FetchLatestBundleOptions(@Nullable String channel) {
21
+ this.appId = null;
22
+ this.channel = channel;
23
+ }
24
+
25
+ public FetchLatestBundleOptions(@Nullable String appId, @Nullable String channel) {
26
+ this.appId = appId;
17
27
  this.channel = channel;
18
28
  }
19
29
 
30
+ @Nullable
31
+ public String getAppId() {
32
+ return appId;
33
+ }
34
+
20
35
  @Nullable
21
36
  public String getChannel() {
22
37
  return channel;
@@ -15,6 +15,9 @@ public class FetchLatestBundleResult implements Result {
15
15
  @Nullable
16
16
  private final String bundleId;
17
17
 
18
+ @Nullable
19
+ private final String channel;
20
+
18
21
  @Nullable
19
22
  private final String checksum;
20
23
 
@@ -30,6 +33,7 @@ public class FetchLatestBundleResult implements Result {
30
33
  public FetchLatestBundleResult(
31
34
  @Nullable ArtifactType artifactType,
32
35
  @Nullable String bundleId,
36
+ @Nullable String channel,
33
37
  @Nullable String checksum,
34
38
  @Nullable JSONObject customProperties,
35
39
  @Nullable String downloadUrl,
@@ -37,12 +41,43 @@ public class FetchLatestBundleResult implements Result {
37
41
  ) {
38
42
  this.artifactType = artifactType;
39
43
  this.bundleId = bundleId;
44
+ this.channel = channel;
40
45
  this.checksum = checksum;
41
46
  this.customProperties = customProperties;
42
47
  this.downloadUrl = downloadUrl;
43
48
  this.signature = signature;
44
49
  }
45
50
 
51
+ @Nullable
52
+ public ArtifactType getArtifactType() {
53
+ return artifactType;
54
+ }
55
+
56
+ @Nullable
57
+ public String getBundleId() {
58
+ return bundleId;
59
+ }
60
+
61
+ @Nullable
62
+ public String getChecksum() {
63
+ return checksum;
64
+ }
65
+
66
+ @Nullable
67
+ public JSONObject getCustomProperties() {
68
+ return customProperties;
69
+ }
70
+
71
+ @Nullable
72
+ public String getDownloadUrl() {
73
+ return downloadUrl;
74
+ }
75
+
76
+ @Nullable
77
+ public String getSignature() {
78
+ return signature;
79
+ }
80
+
46
81
  @NonNull
47
82
  public JSObject toJSObject() {
48
83
  JSObject result = new JSObject();
@@ -52,6 +87,9 @@ public class FetchLatestBundleResult implements Result {
52
87
  result.put("artifactType", "zip");
53
88
  }
54
89
  result.put("bundleId", bundleId == null ? JSONObject.NULL : bundleId);
90
+ if (channel != null) {
91
+ result.put("channel", channel);
92
+ }
55
93
  if (checksum != null) {
56
94
  result.put("checksum", checksum);
57
95
  }
@@ -0,0 +1,169 @@
1
+ package io.capawesome.capacitorjs.plugins.liveupdate.providers.ionic
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import com.getcapacitor.Logger
6
+ import io.capawesome.capacitorjs.plugins.liveupdate.LiveUpdate
7
+ import io.capawesome.capacitorjs.plugins.liveupdate.LiveUpdateConfig
8
+ import io.capawesome.capacitorjs.plugins.liveupdate.LiveUpdatePlugin
9
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.DownloadBundleOptions
10
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.FetchLatestBundleOptions
11
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.FetchLatestBundleResult
12
+ import io.capawesome.capacitorjs.plugins.liveupdate.enums.ArtifactType
13
+ import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.EmptyCallback
14
+ import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.NonEmptyCallback
15
+ import io.ionic.liveupdateprovider.MetadataSyncResult
16
+ import io.ionic.liveupdateprovider.ProviderError
17
+ import io.ionic.liveupdateprovider.ProviderManager
18
+ import io.ionic.liveupdateprovider.ProviderSyncResult
19
+ import java.io.File
20
+ import kotlin.coroutines.resume
21
+ import kotlin.coroutines.resumeWithException
22
+ import kotlin.coroutines.suspendCoroutine
23
+ import org.json.JSONObject
24
+
25
+ /**
26
+ * Ionic Live Update Provider manager backed by the Capawesome live update plugin.
27
+ *
28
+ * Each manager instance is scoped by a `managerKey` so that multiple shells (Portals or
29
+ * Federated Capacitor apps) can persist their own active bundle without colliding with each
30
+ * other or with the standalone plugin's `currentBundleId` / `nextBundleId` state.
31
+ *
32
+ * Provider configuration keys (V1):
33
+ * - `managerKey` (required) — scopes per-manager persisted state
34
+ * - `appId` (optional) — Capawesome Cloud app UUID; falls back to plugin config
35
+ * - `channel` (optional) — channel to sync; falls back to plugin config
36
+ */
37
+ class LiveUpdateIonicManager
38
+ @Throws(ProviderError.InvalidConfiguration::class)
39
+ constructor(context: Context, configuration: Map<String, *>, private val liveUpdate: LiveUpdate) : ProviderManager {
40
+
41
+ /**
42
+ * Creates a manager without a running Capacitor plugin, e.g. for Ionic Portals hosts
43
+ * that construct the manager directly and attach it to a Portal via
44
+ * `setLiveUpdateProviderManager(...)`. Uses a headless [LiveUpdate] instance with the
45
+ * default configuration; pass `appId` and `channel` via the configuration map.
46
+ */
47
+ @Throws(ProviderError.InvalidConfiguration::class)
48
+ constructor(context: Context, configuration: Map<String, *>) : this(context, configuration, LiveUpdate(LiveUpdateConfig(), context))
49
+
50
+ companion object {
51
+ const val SHARED_PREFERENCES_NAME = "CapawesomeLiveUpdateIonicProvider"
52
+ const val PREF_PREFIX_LAST_SYNCED_BUNDLE_ID = "lastSyncedBundleId."
53
+ }
54
+
55
+ override var latestAppDirectory: File? = null
56
+ private set
57
+
58
+ private val appId: String?
59
+ private val channel: String?
60
+ private val managerKey: String
61
+ private val sharedPreferences: SharedPreferences
62
+
63
+ init {
64
+ val managerKeyValue = configuration["managerKey"]
65
+ if (managerKeyValue !is String || managerKeyValue.isEmpty()) {
66
+ throw ProviderError.InvalidConfiguration(LiveUpdatePlugin.ERROR_MANAGER_KEY_MISSING)
67
+ }
68
+ managerKey = managerKeyValue
69
+ appId = configuration["appId"] as? String
70
+ channel = configuration["channel"] as? String
71
+ sharedPreferences = context.applicationContext.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)
72
+
73
+ // Restore the last synced bundle directory, if any.
74
+ val persistedBundleId = sharedPreferences.getString(getLastSyncedBundleIdKey(), null)
75
+ if (persistedBundleId != null) {
76
+ latestAppDirectory = liveUpdate.getBundleDirectory(persistedBundleId)
77
+ }
78
+ }
79
+
80
+ override suspend fun sync(): ProviderSyncResult? {
81
+ try {
82
+ val result = fetchLatestBundle()
83
+
84
+ // No update available; nothing to report.
85
+ val bundleId = result.bundleId ?: return null
86
+
87
+ // Bundle already on disk — just point latestAppDirectory at it and persist.
88
+ val existingDirectory = liveUpdate.getBundleDirectory(bundleId)
89
+ if (existingDirectory != null) {
90
+ applySyncedBundle(bundleId, existingDirectory)
91
+ return MetadataSyncResult(buildMetadata(bundleId, result.customProperties))
92
+ }
93
+
94
+ // Otherwise, download and then apply.
95
+ downloadBundle(bundleId, result)
96
+ val downloadedDirectory =
97
+ liveUpdate.getBundleDirectory(bundleId) ?: throw Exception(LiveUpdatePlugin.ERROR_BUNDLE_DIRECTORY_NOT_FOUND)
98
+ applySyncedBundle(bundleId, downloadedDirectory)
99
+ return MetadataSyncResult(buildMetadata(bundleId, result.customProperties))
100
+ } catch (exception: Exception) {
101
+ Logger.error(LiveUpdatePlugin.TAG, "Ionic provider sync failed: ${exception.message}", exception)
102
+ throw exception
103
+ }
104
+ }
105
+
106
+ private fun applySyncedBundle(bundleId: String, directory: File) {
107
+ latestAppDirectory = directory
108
+ sharedPreferences.edit().putString(getLastSyncedBundleIdKey(), bundleId).apply()
109
+ }
110
+
111
+ private fun buildMetadata(bundleId: String, customProperties: JSONObject?): Map<String, Any> {
112
+ val metadata = mutableMapOf<String, Any>("bundleId" to bundleId)
113
+ channel?.let { metadata["channel"] = it }
114
+ customProperties?.let {
115
+ val customPropertiesMap = jsonObjectToMap(it)
116
+ if (customPropertiesMap.isNotEmpty()) {
117
+ metadata["customProperties"] = customPropertiesMap
118
+ }
119
+ }
120
+ return metadata
121
+ }
122
+
123
+ private suspend fun downloadBundle(bundleId: String, result: FetchLatestBundleResult) {
124
+ val downloadUrl = result.downloadUrl
125
+ if (downloadUrl.isNullOrEmpty()) {
126
+ throw Exception(LiveUpdatePlugin.ERROR_DOWNLOAD_URL_MISSING)
127
+ }
128
+ val artifactType = if (result.artifactType == ArtifactType.MANIFEST) "manifest" else "zip"
129
+ val options = DownloadBundleOptions(artifactType, bundleId, result.checksum, result.signature, downloadUrl)
130
+ return suspendCoroutine { continuation ->
131
+ liveUpdate.downloadBundle(
132
+ options,
133
+ object : EmptyCallback {
134
+ override fun success() = continuation.resume(Unit)
135
+
136
+ override fun error(exception: Exception) = continuation.resumeWithException(exception)
137
+ }
138
+ )
139
+ }
140
+ }
141
+
142
+ private suspend fun fetchLatestBundle(): FetchLatestBundleResult = suspendCoroutine { continuation ->
143
+ liveUpdate.fetchLatestBundle(
144
+ FetchLatestBundleOptions(appId, channel),
145
+ object : NonEmptyCallback<FetchLatestBundleResult> {
146
+ override fun success(result: FetchLatestBundleResult) = continuation.resume(result)
147
+
148
+ override fun error(exception: Exception) = continuation.resumeWithException(exception)
149
+ }
150
+ )
151
+ }
152
+
153
+ private fun getLastSyncedBundleIdKey(): String {
154
+ return PREF_PREFIX_LAST_SYNCED_BUNDLE_ID + managerKey
155
+ }
156
+
157
+ private fun jsonObjectToMap(jsonObject: JSONObject): Map<String, Any> {
158
+ val map = mutableMapOf<String, Any>()
159
+ val keys = jsonObject.keys()
160
+ while (keys.hasNext()) {
161
+ val key = keys.next()
162
+ val value = jsonObject.opt(key)
163
+ if (value != null && value != JSONObject.NULL) {
164
+ map[key] = value
165
+ }
166
+ }
167
+ return map
168
+ }
169
+ }
package/dist/docs.json CHANGED
@@ -881,6 +881,18 @@
881
881
  "complexTypes": [],
882
882
  "type": "string | null"
883
883
  },
884
+ {
885
+ "name": "channel",
886
+ "tags": [
887
+ {
888
+ "text": "8.3.0",
889
+ "name": "since"
890
+ }
891
+ ],
892
+ "docs": "The name of the channel that the bundle is actually from.\n\nThis is the resolved channel after applying any forced channel assignment\nand may differ from the channel set by the Live Update SDK.",
893
+ "complexTypes": [],
894
+ "type": "string | undefined"
895
+ },
884
896
  {
885
897
  "name": "checksum",
886
898
  "tags": [
@@ -955,7 +967,7 @@
955
967
  "name": "since"
956
968
  }
957
969
  ],
958
- "docs": "The name of the channel where the latest bundle is fetched from.",
970
+ "docs": "The preferred channel name from which the latest bundle should be fetched.\n\nThis is the SDK preference and may be overridden by a forced channel assignment\nconfigured in the Capawesome Cloud Console.",
959
971
  "complexTypes": [],
960
972
  "type": "string | undefined"
961
973
  }
@@ -1521,7 +1533,7 @@
1521
1533
  "name": "since"
1522
1534
  }
1523
1535
  ],
1524
- "docs": "The name of the channel where the latest bundle is fetched from.",
1536
+ "docs": "The preferred channel name from which the latest bundle should be fetched.\n\nThis is the SDK preference and may be overridden by a forced channel assignment\nconfigured in the Capawesome Cloud Console.",
1525
1537
  "complexTypes": [],
1526
1538
  "type": "string | undefined"
1527
1539
  }