@capawesome/capacitor-live-update 8.6.0 → 8.7.1

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 +15 -0
  2. package/Package.swift +21 -5
  3. package/README.md +35 -2
  4. package/android/build.gradle +19 -0
  5. package/android/consumer-rules.pro +5 -0
  6. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +181 -56
  7. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java +47 -1
  8. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/DownloadBundleOptions.java +11 -5
  9. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/FetchLatestBundleOptions.java +23 -1
  10. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/FetchLatestBundleResult.java +30 -0
  11. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/providers/ionic/LiveUpdateIonicManager.kt +229 -0
  12. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/providers/ionic/LiveUpdateIonicPreferences.kt +85 -0
  13. package/ios/Plugin/Classes/Options/DownloadBundleOptions.swift +7 -6
  14. package/ios/Plugin/Classes/Options/FetchLatestBundleOptions.swift +19 -3
  15. package/ios/Plugin/Classes/Results/FetchLatestBundleResult.swift +24 -0
  16. package/ios/Plugin/Enums/CustomError.swift +13 -0
  17. package/ios/Plugin/LiveUpdate.swift +140 -45
  18. package/ios/Plugin/LiveUpdatePlugin.swift +16 -3
  19. package/ios/Plugin/Providers/Ionic/LiveUpdateIonicManager.swift +187 -0
  20. package/ios/Plugin/Providers/Ionic/LiveUpdateIonicPreferences.swift +81 -0
  21. package/ios/Plugin/Providers/Ionic/LiveUpdatePlugin+LiveUpdateProvider.swift +15 -0
  22. package/package.json +2 -1
@@ -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,18 @@ 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 java.util.Map;
35
39
 
36
40
  @CapacitorPlugin(name = "LiveUpdate")
37
41
  public class LiveUpdatePlugin extends Plugin {
38
42
 
39
43
  public static final String TAG = "LiveUpdate";
40
- public static final String VERSION = "8.6.0";
44
+ public static final String VERSION = "8.7.1";
41
45
  public static final String SHARED_PREFERENCES_NAME = "CapawesomeLiveUpdate"; // DO NOT CHANGE
42
46
  public static final String ERROR_APP_ID_MISSING = "appId must be configured.";
47
+ public static final String ERROR_BUNDLE_DIRECTORY_NOT_FOUND = "Bundle directory could not be resolved.";
43
48
  public static final String ERROR_BUNDLE_EXISTS = "bundle already exists.";
44
49
  public static final String ERROR_BUNDLE_ID_MISSING = "bundleId must be provided.";
45
50
  public static final String ERROR_BUNDLE_INDEX_HTML_MISSING = "The bundle does not contain an index.html file.";
@@ -49,7 +54,9 @@ public class LiveUpdatePlugin extends Plugin {
49
54
  public static final String ERROR_CHECKSUM_MISMATCH = "Checksum mismatch.";
50
55
  public static final String ERROR_CUSTOM_ID_MISSING = "customId must be provided.";
51
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.";
52
58
  public static final String ERROR_HTTP_TIMEOUT = "Request timed out.";
59
+ public static final String ERROR_PLUGIN_NOT_INITIALIZED = "Plugin is not initialized.";
53
60
  public static final String ERROR_URL_MISSING = "url must be provided.";
54
61
  public static final String ERROR_SIGNATURE_VERIFICATION_FAILED = "Signature verification failed.";
55
62
  public static final String ERROR_PUBLIC_KEY_INVALID = "Invalid public key.";
@@ -73,6 +80,29 @@ public class LiveUpdatePlugin extends Plugin {
73
80
  }
74
81
  }
75
82
 
83
+ /**
84
+ * Creates a manager for the Ionic Live Update Provider SDK. Invoked natively by
85
+ * Federated Capacitor after resolving this plugin by its Capacitor plugin name.
86
+ *
87
+ * The SDK is a compileOnly dependency, so this class must load without it:
88
+ * - It does NOT implement the SDK's `LiveUpdateProvider` interface, because superinterfaces
89
+ * are resolved when the class is loaded.
90
+ * - The return type is `Object` instead of `ProviderManager`, because Capacitor calls
91
+ * `Class.getDeclaredMethods()` on every plugin class at load, which resolves the return
92
+ * and parameter types of all declared methods.
93
+ *
94
+ * Federated Capacitor falls back to resolving `createManager(Context, Map)` reflectively by
95
+ * name and parameter types and casts the result, so neither must change. SDK types are only
96
+ * referenced inside the method body, which is resolved lazily.
97
+ */
98
+ @NonNull
99
+ public Object createManager(@NonNull Context context, @NonNull Map<String, ?> configuration) throws ProviderError.InvalidConfiguration {
100
+ if (implementation == null) {
101
+ throw new ProviderError.InvalidConfiguration(ERROR_PLUGIN_NOT_INITIALIZED, null);
102
+ }
103
+ return new LiveUpdateIonicManager(context, configuration, implementation);
104
+ }
105
+
76
106
  @Override
77
107
  protected void handleOnResume() {
78
108
  super.handleOnResume();
@@ -102,6 +132,22 @@ public class LiveUpdatePlugin extends Plugin {
102
132
  }
103
133
  }
104
134
 
135
+ @Override
136
+ protected void handleOnStart() {
137
+ super.handleOnStart();
138
+ if (implementation != null) {
139
+ implementation.handleOnStart();
140
+ }
141
+ }
142
+
143
+ @Override
144
+ protected void handleOnStop() {
145
+ super.handleOnStop();
146
+ if (implementation != null) {
147
+ implementation.handleOnStop();
148
+ }
149
+ }
150
+
105
151
  @PluginMethod
106
152
  public void clearBlockedBundles(PluginCall call) {
107
153
  try {
@@ -28,11 +28,17 @@ public class DownloadBundleOptions {
28
28
  @Nullable String signature,
29
29
  @NonNull String url
30
30
  ) {
31
- if (artifactType.equals("manifest")) {
32
- this.artifactType = ArtifactType.MANIFEST;
33
- } else {
34
- this.artifactType = ArtifactType.ZIP;
35
- }
31
+ this(artifactType.equals("manifest") ? ArtifactType.MANIFEST : ArtifactType.ZIP, bundleId, checksum, signature, url);
32
+ }
33
+
34
+ public DownloadBundleOptions(
35
+ @NonNull ArtifactType artifactType,
36
+ @NonNull String bundleId,
37
+ @Nullable String checksum,
38
+ @Nullable String signature,
39
+ @NonNull String url
40
+ ) {
41
+ this.artifactType = artifactType;
36
42
  this.bundleId = bundleId;
37
43
  this.checksum = checksum;
38
44
  this.signature = signature;
@@ -6,17 +6,39 @@ import com.getcapacitor.PluginCall;
6
6
 
7
7
  public class FetchLatestBundleOptions {
8
8
 
9
+ @Nullable
10
+ private final String appId;
11
+
12
+ @Nullable
13
+ private final String bundleId;
14
+
9
15
  @Nullable
10
16
  private final String channel;
11
17
 
12
18
  public FetchLatestBundleOptions(@NonNull PluginCall call) {
13
- this.channel = call.getString("channel", null);
19
+ this(null, null, call.getString("channel", null));
14
20
  }
15
21
 
16
22
  public FetchLatestBundleOptions(@Nullable String channel) {
23
+ this(null, null, channel);
24
+ }
25
+
26
+ public FetchLatestBundleOptions(@Nullable String appId, @Nullable String bundleId, @Nullable String channel) {
27
+ this.appId = appId;
28
+ this.bundleId = bundleId;
17
29
  this.channel = channel;
18
30
  }
19
31
 
32
+ @Nullable
33
+ public String getAppId() {
34
+ return appId;
35
+ }
36
+
37
+ @Nullable
38
+ public String getBundleId() {
39
+ return bundleId;
40
+ }
41
+
20
42
  @Nullable
21
43
  public String getChannel() {
22
44
  return channel;
@@ -48,6 +48,36 @@ public class FetchLatestBundleResult implements Result {
48
48
  this.signature = signature;
49
49
  }
50
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
+
51
81
  @NonNull
52
82
  public JSObject toJSObject() {
53
83
  JSObject result = new JSObject();
@@ -0,0 +1,229 @@
1
+ package io.capawesome.capacitorjs.plugins.liveupdate.providers.ionic
2
+
3
+ import android.content.Context
4
+ import com.getcapacitor.Logger
5
+ import io.capawesome.capacitorjs.plugins.liveupdate.LiveUpdate
6
+ import io.capawesome.capacitorjs.plugins.liveupdate.LiveUpdateConfig
7
+ import io.capawesome.capacitorjs.plugins.liveupdate.LiveUpdatePlugin
8
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.DownloadBundleOptions
9
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.FetchLatestBundleOptions
10
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.FetchLatestBundleResult
11
+ import io.capawesome.capacitorjs.plugins.liveupdate.enums.ArtifactType
12
+ import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.EmptyCallback
13
+ import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.NonEmptyCallback
14
+ import io.ionic.liveupdateprovider.MetadataSyncResult
15
+ import io.ionic.liveupdateprovider.ProviderError
16
+ import io.ionic.liveupdateprovider.ProviderManager
17
+ import io.ionic.liveupdateprovider.ProviderSyncResult
18
+ import java.io.File
19
+ import java.util.concurrent.ConcurrentHashMap
20
+ import kotlin.coroutines.resume
21
+ import kotlin.coroutines.resumeWithException
22
+ import kotlin.coroutines.suspendCoroutine
23
+ import kotlinx.coroutines.CoroutineScope
24
+ import kotlinx.coroutines.Deferred
25
+ import kotlinx.coroutines.SupervisorJob
26
+ import kotlinx.coroutines.async
27
+ import org.json.JSONObject
28
+
29
+ /**
30
+ * Ionic Live Update Provider manager backed by the Capawesome live update plugin.
31
+ *
32
+ * Each manager persists its active bundle scoped by its resolved app ID and channel, so that
33
+ * multiple shells (Portals or Federated Capacitor apps) do not collide with each other or with
34
+ * the standalone plugin's `currentBundleId` / `nextBundleId` state.
35
+ *
36
+ * Provider configuration keys (V1):
37
+ * - `appId` (optional) — Capawesome Cloud app UUID; falls back to plugin config, required in either
38
+ * - `channel` (optional) — channel to sync; falls back to plugin config
39
+ * - `publicKey`, `serverDomain`, `httpTimeout` (optional) — same as the plugin config; only
40
+ * applied by the headless constructor, otherwise the running plugin's config is used
41
+ */
42
+ class LiveUpdateIonicManager
43
+ @Throws(ProviderError.InvalidConfiguration::class)
44
+ constructor(context: Context, configuration: Map<String, *>, private val liveUpdate: LiveUpdate) : ProviderManager {
45
+
46
+ /**
47
+ * Creates a manager without a running Capacitor plugin, e.g. for Ionic Portals hosts
48
+ * that construct the manager directly and attach it to a Portal via
49
+ * `setLiveUpdateProviderManager(...)`. Uses a headless [LiveUpdate] instance configured
50
+ * via the configuration map.
51
+ */
52
+ @Throws(ProviderError.InvalidConfiguration::class)
53
+ constructor(
54
+ context: Context,
55
+ configuration: Map<String, *>
56
+ ) : this(context, configuration, LiveUpdate(createLiveUpdateConfig(configuration), context.applicationContext))
57
+
58
+ companion object {
59
+ /**
60
+ * Downloads in flight per bundle ID, shared by all managers, so that concurrent syncs
61
+ * download the same bundle only once.
62
+ */
63
+ private val bundleDownloads = ConcurrentHashMap<String, Deferred<Unit>>()
64
+ private val bundleDownloadScope = CoroutineScope(SupervisorJob())
65
+
66
+ @Throws(ProviderError.InvalidConfiguration::class)
67
+ private fun createLiveUpdateConfig(configuration: Map<String, *>): LiveUpdateConfig {
68
+ val config = LiveUpdateConfig()
69
+ getOptionalValue<String>(configuration, "publicKey")?.let { config.publicKey = it }
70
+ getOptionalValue<String>(configuration, "serverDomain")?.let { config.serverDomain = it }
71
+ getOptionalValue<Number>(configuration, "httpTimeout")?.let { config.httpTimeout = it.toInt() }
72
+ return config
73
+ }
74
+
75
+ @Throws(ProviderError.InvalidConfiguration::class)
76
+ private inline fun <reified T> getOptionalValue(configuration: Map<String, *>, key: String): T? {
77
+ val value = configuration[key] ?: return null
78
+ return value as? T ?: throw ProviderError.InvalidConfiguration("$key has an invalid type.")
79
+ }
80
+ }
81
+
82
+ override var latestAppDirectory: File? = null
83
+ private set
84
+
85
+ private val appId: String
86
+ private val channel: String?
87
+ private val preferences: LiveUpdateIonicPreferences
88
+ private val versionCode: String = liveUpdate.versionCodeAsString
89
+ private var syncedBundleId: String? = null
90
+
91
+ init {
92
+ val resolvedAppId = configuration["appId"] as? String ?: liveUpdate.appId
93
+ if (resolvedAppId.isNullOrEmpty()) {
94
+ throw ProviderError.InvalidConfiguration(LiveUpdatePlugin.ERROR_APP_ID_MISSING)
95
+ }
96
+ appId = resolvedAppId
97
+ channel = configuration["channel"] as? String ?: liveUpdate.channel
98
+ val stateKey = if (channel.isNullOrEmpty()) appId else "$appId.$channel"
99
+ val sharedPreferences =
100
+ context.applicationContext.getSharedPreferences(LiveUpdateIonicPreferences.SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)
101
+ preferences = LiveUpdateIonicPreferences(sharedPreferences, stateKey)
102
+ preferences.resetLastSyncedBundleIdIfVersionChanged(versionCode)
103
+ restoreSyncedBundle()
104
+ deleteSupersededBundles()
105
+ }
106
+
107
+ override suspend fun sync(): ProviderSyncResult? {
108
+ try {
109
+ val result = fetchLatestBundle()
110
+
111
+ // No update available; nothing to report.
112
+ val bundleId = result.bundleId
113
+ if (bundleId == null || bundleId == syncedBundleId) {
114
+ return null
115
+ }
116
+
117
+ // Use the bundle on disk, or download it (or wait for a running download of the same bundle).
118
+ val directory = liveUpdate.getBundleDirectory(bundleId) ?: run {
119
+ downloadBundleOnce(bundleId, result)
120
+ liveUpdate.getBundleDirectory(bundleId) ?: throw Exception(LiveUpdatePlugin.ERROR_BUNDLE_DIRECTORY_NOT_FOUND)
121
+ }
122
+ applySyncedBundle(bundleId, directory)
123
+ return MetadataSyncResult(buildMetadata(bundleId, result.customProperties))
124
+ } catch (exception: Exception) {
125
+ Logger.error(LiveUpdatePlugin.TAG, "Ionic provider sync failed: ${exception.message}", exception)
126
+ throw exception
127
+ }
128
+ }
129
+
130
+ private fun applySyncedBundle(bundleId: String, directory: File) {
131
+ syncedBundleId = bundleId
132
+ latestAppDirectory = directory
133
+ preferences.setLastSyncedBundleId(bundleId, versionCode)
134
+ }
135
+
136
+ private fun buildMetadata(bundleId: String, customProperties: JSONObject?): Map<String, Any> {
137
+ val metadata = mutableMapOf<String, Any>("bundleId" to bundleId)
138
+ channel?.let { metadata["channel"] = it }
139
+ customProperties?.let {
140
+ val customPropertiesMap = jsonObjectToMap(it)
141
+ if (customPropertiesMap.isNotEmpty()) {
142
+ metadata["customProperties"] = customPropertiesMap
143
+ }
144
+ }
145
+ return metadata
146
+ }
147
+
148
+ /**
149
+ * Deletes the bundles this manager no longer points at, unless another manager or the
150
+ * plugin still uses them. Never runs right after a sync, because a visible portal may
151
+ * still serve the previous bundle.
152
+ */
153
+ private fun deleteSupersededBundles() {
154
+ val supersededBundleIds = preferences.getSupersededBundleIds()
155
+ if (supersededBundleIds.isEmpty()) {
156
+ return
157
+ }
158
+ val referencedBundleIds = preferences.getLastSyncedBundleIdsOfAllManagers()
159
+ val deletedBundleIds = supersededBundleIds
160
+ .filter { it !in referencedBundleIds && liveUpdate.tryDeleteUnusedBundle(it) }
161
+ .toSet()
162
+ preferences.removeSupersededBundleIds(deletedBundleIds)
163
+ }
164
+
165
+ private suspend fun downloadBundle(bundleId: String, result: FetchLatestBundleResult) {
166
+ val downloadUrl = result.downloadUrl
167
+ if (downloadUrl.isNullOrEmpty()) {
168
+ throw Exception(LiveUpdatePlugin.ERROR_DOWNLOAD_URL_MISSING)
169
+ }
170
+ val artifactType = result.artifactType ?: ArtifactType.ZIP
171
+ val options = DownloadBundleOptions(artifactType, bundleId, result.checksum, result.signature, downloadUrl)
172
+ suspendCoroutine { continuation ->
173
+ liveUpdate.downloadBundle(
174
+ options,
175
+ object : EmptyCallback {
176
+ override fun success() = continuation.resume(Unit)
177
+
178
+ override fun error(exception: Exception) = continuation.resumeWithException(exception)
179
+ }
180
+ )
181
+ }
182
+ }
183
+
184
+ private suspend fun downloadBundleOnce(bundleId: String, result: FetchLatestBundleResult) {
185
+ val download = bundleDownloads.computeIfAbsent(bundleId) {
186
+ bundleDownloadScope.async { downloadBundle(bundleId, result) }
187
+ }
188
+ try {
189
+ download.await()
190
+ } finally {
191
+ bundleDownloads.remove(bundleId, download)
192
+ }
193
+ }
194
+
195
+ private suspend fun fetchLatestBundle(): FetchLatestBundleResult = suspendCoroutine { continuation ->
196
+ liveUpdate.fetchLatestBundle(
197
+ FetchLatestBundleOptions(appId, syncedBundleId, channel),
198
+ object : NonEmptyCallback<FetchLatestBundleResult> {
199
+ override fun success(result: FetchLatestBundleResult) = continuation.resume(result)
200
+
201
+ override fun error(exception: Exception) = continuation.resumeWithException(exception)
202
+ }
203
+ )
204
+ }
205
+
206
+ private fun jsonObjectToMap(jsonObject: JSONObject): Map<String, Any> {
207
+ val map = mutableMapOf<String, Any>()
208
+ val keys = jsonObject.keys()
209
+ while (keys.hasNext()) {
210
+ val key = keys.next()
211
+ val value = jsonObject.opt(key)
212
+ if (value != null && value != JSONObject.NULL) {
213
+ map[key] = value
214
+ }
215
+ }
216
+ return map
217
+ }
218
+
219
+ /**
220
+ * Restores the last synced bundle, if it is still on disk, so the host can load it offline
221
+ * and the next sync reports it as the bundle in use.
222
+ */
223
+ private fun restoreSyncedBundle() {
224
+ val bundleId = preferences.getLastSyncedBundleId() ?: return
225
+ val directory = liveUpdate.getBundleDirectory(bundleId) ?: return
226
+ syncedBundleId = bundleId
227
+ latestAppDirectory = directory
228
+ }
229
+ }
@@ -0,0 +1,85 @@
1
+ package io.capawesome.capacitorjs.plugins.liveupdate.providers.ionic
2
+
3
+ import android.content.SharedPreferences
4
+
5
+ /**
6
+ * Persisted state of one provider manager, scoped by its state key (`<appId>` or `<appId>.<channel>`).
7
+ *
8
+ * All managers share the same [SharedPreferences] file, so a manager can see which bundles
9
+ * the other managers still point at.
10
+ */
11
+ class LiveUpdateIonicPreferences(private val sharedPreferences: SharedPreferences, stateKey: String) {
12
+
13
+ companion object {
14
+ const val SHARED_PREFERENCES_NAME = "CapawesomeLiveUpdateIonicProvider"
15
+ private const val LAST_SYNCED_BUNDLE_ID_PREFIX = "lastSyncedBundleId."
16
+ private const val LAST_VERSION_CODE_PREFIX = "lastVersionCode."
17
+ private const val SUPERSEDED_BUNDLE_IDS_PREFIX = "supersededBundleIds."
18
+ }
19
+
20
+ private val lastSyncedBundleIdKey = LAST_SYNCED_BUNDLE_ID_PREFIX + stateKey
21
+ private val lastVersionCodeKey = LAST_VERSION_CODE_PREFIX + stateKey
22
+ private val supersededBundleIdsKey = SUPERSEDED_BUNDLE_IDS_PREFIX + stateKey
23
+
24
+ fun getLastSyncedBundleId(): String? = sharedPreferences.getString(lastSyncedBundleIdKey, null)
25
+
26
+ /**
27
+ * @return The last synced bundle IDs of all managers.
28
+ */
29
+ fun getLastSyncedBundleIdsOfAllManagers(): Set<String> = sharedPreferences.all
30
+ .filterKeys { it.startsWith(LAST_SYNCED_BUNDLE_ID_PREFIX) }
31
+ .values
32
+ .filterIsInstance<String>()
33
+ .toSet()
34
+
35
+ /**
36
+ * @return The bundles this manager no longer points at and that may be deleted once unused.
37
+ */
38
+ fun getSupersededBundleIds(): Set<String> = sharedPreferences.getStringSet(supersededBundleIdsKey, null)?.toSet() ?: emptySet()
39
+
40
+ fun removeSupersededBundleIds(bundleIds: Set<String>) {
41
+ if (bundleIds.isEmpty()) {
42
+ return
43
+ }
44
+ val remainingBundleIds = getSupersededBundleIds() - bundleIds
45
+ val editor = sharedPreferences.edit()
46
+ if (remainingBundleIds.isEmpty()) {
47
+ editor.remove(supersededBundleIdsKey)
48
+ } else {
49
+ editor.putStringSet(supersededBundleIdsKey, remainingBundleIds)
50
+ }
51
+ editor.apply()
52
+ }
53
+
54
+ /**
55
+ * Forgets the last synced bundle if it was synced on another app version code,
56
+ * because the bundle was built for that native version.
57
+ */
58
+ fun resetLastSyncedBundleIdIfVersionChanged(versionCode: String) {
59
+ val lastSyncedBundleId = getLastSyncedBundleId() ?: return
60
+ val lastVersionCode = sharedPreferences.getString(lastVersionCodeKey, null) ?: return
61
+ if (lastVersionCode == versionCode) {
62
+ return
63
+ }
64
+ sharedPreferences.edit()
65
+ .putStringSet(supersededBundleIdsKey, getSupersededBundleIds() + lastSyncedBundleId)
66
+ .remove(lastSyncedBundleIdKey)
67
+ .remove(lastVersionCodeKey)
68
+ .apply()
69
+ }
70
+
71
+ /**
72
+ * Points this manager at the given bundle, synced on the given app version code,
73
+ * and records the previous bundle as superseded.
74
+ */
75
+ fun setLastSyncedBundleId(bundleId: String, versionCode: String) {
76
+ val editor = sharedPreferences.edit()
77
+ val previousBundleId = getLastSyncedBundleId()
78
+ if (previousBundleId != null && previousBundleId != bundleId) {
79
+ editor.putStringSet(supersededBundleIdsKey, getSupersededBundleIds() + previousBundleId)
80
+ }
81
+ editor.putString(lastSyncedBundleIdKey, bundleId)
82
+ .putString(lastVersionCodeKey, versionCode)
83
+ .apply()
84
+ }
85
+ }
@@ -8,12 +8,13 @@ import Capacitor
8
8
  private var signature: String?
9
9
  private var url: String
10
10
 
11
- init(artifactType: String, bundleId: String, checksum: String?, signature: String?, url: String) {
12
- if artifactType == "manifest" {
13
- self.artifactType = .manifest
14
- } else {
15
- self.artifactType = .zip
16
- }
11
+ convenience init(artifactType: String, bundleId: String, checksum: String?, signature: String?, url: String) {
12
+ let artifactType: ArtifactType = artifactType == "manifest" ? .manifest : .zip
13
+ self.init(artifactType: artifactType, bundleId: bundleId, checksum: checksum, signature: signature, url: url)
14
+ }
15
+
16
+ init(artifactType: ArtifactType, bundleId: String, checksum: String?, signature: String?, url: String) {
17
+ self.artifactType = artifactType
17
18
  self.bundleId = bundleId
18
19
  self.checksum = checksum
19
20
  self.signature = signature
@@ -2,16 +2,32 @@ import Foundation
2
2
  import Capacitor
3
3
 
4
4
  @objc public class FetchLatestBundleOptions: NSObject {
5
+ private var appId: String?
6
+ private var bundleId: String?
5
7
  private var channel: String?
6
8
 
7
- init(_ call: CAPPluginCall) {
8
- self.channel = call.getString("channel")
9
+ convenience init(_ call: CAPPluginCall) {
10
+ self.init(appId: nil, bundleId: nil, channel: call.getString("channel"))
9
11
  }
10
12
 
11
- init(channel: String?) {
13
+ convenience init(channel: String?) {
14
+ self.init(appId: nil, bundleId: nil, channel: channel)
15
+ }
16
+
17
+ init(appId: String?, bundleId: String?, channel: String?) {
18
+ self.appId = appId
19
+ self.bundleId = bundleId
12
20
  self.channel = channel
13
21
  }
14
22
 
23
+ func getAppId() -> String? {
24
+ return appId
25
+ }
26
+
27
+ func getBundleId() -> String? {
28
+ return bundleId
29
+ }
30
+
15
31
  func getChannel() -> String? {
16
32
  return channel
17
33
  }
@@ -20,6 +20,30 @@ import Capacitor
20
20
  self.signature = signature
21
21
  }
22
22
 
23
+ func getArtifactType() -> ArtifactType? {
24
+ return artifactType
25
+ }
26
+
27
+ public func getBundleId() -> String? {
28
+ return bundleId
29
+ }
30
+
31
+ public func getChecksum() -> String? {
32
+ return checksum
33
+ }
34
+
35
+ public func getCustomProperties() -> [String: Any]? {
36
+ return customProperties
37
+ }
38
+
39
+ public func getDownloadUrl() -> String? {
40
+ return downloadUrl
41
+ }
42
+
43
+ public func getSignature() -> String? {
44
+ return signature
45
+ }
46
+
23
47
  public func toJSObject() -> AnyObject {
24
48
  var result = JSObject()
25
49
  if artifactType == .manifest {
@@ -3,6 +3,7 @@ import Foundation
3
3
  public enum CustomError: Error {
4
4
  case appIdMissing
5
5
  case bundleAlreadyExists
6
+ case bundleDirectoryNotFound
6
7
  case bundleIdMissing
7
8
  case bundleIndexHtmlMissing
8
9
  case bundleNotFound
@@ -10,9 +11,12 @@ public enum CustomError: Error {
10
11
  case channelMissing
11
12
  case checksumCalculationFailed
12
13
  case checksumMismatch
14
+ case configurationValueInvalid(key: String)
13
15
  case customIdMissing
14
16
  case downloadFailed
17
+ case downloadUrlMissing
15
18
  case httpTimeout
19
+ case pluginNotInitialized
16
20
  case signatureMissing
17
21
  case signatureVerificationFailed
18
22
  case syncInProgress
@@ -27,6 +31,8 @@ extension CustomError: LocalizedError {
27
31
  return NSLocalizedString("appId must be configured.", comment: "appIdMissing")
28
32
  case .bundleAlreadyExists:
29
33
  return NSLocalizedString("bundle already exists.", comment: "bundleAlreadyExists")
34
+ case .bundleDirectoryNotFound:
35
+ return NSLocalizedString("Bundle directory could not be resolved.", comment: "bundleDirectoryNotFound")
30
36
  case .bundleIdMissing:
31
37
  return NSLocalizedString("bundleId must be provided.", comment: "bundleIdMissing")
32
38
  case .bundleIndexHtmlMissing:
@@ -41,12 +47,19 @@ extension CustomError: LocalizedError {
41
47
  return NSLocalizedString("Failed to calculate checksum.", comment: "checksumCalculationFailed")
42
48
  case .checksumMismatch:
43
49
  return NSLocalizedString("Checksum mismatch.", comment: "checksumMismatch")
50
+ case .configurationValueInvalid(let key):
51
+ let format = NSLocalizedString("%@ has an invalid type.", comment: "configurationValueInvalid")
52
+ return String(format: format, key)
44
53
  case .customIdMissing:
45
54
  return NSLocalizedString("customId must be provided.", comment: "customIdMissing")
46
55
  case .downloadFailed:
47
56
  return NSLocalizedString("Bundle could not be downloaded.", comment: "downloadFailed")
57
+ case .downloadUrlMissing:
58
+ return NSLocalizedString("Bundle does not have a valid download URL.", comment: "downloadUrlMissing")
48
59
  case .httpTimeout:
49
60
  return NSLocalizedString("Request timed out.", comment: "httpTimeout")
61
+ case .pluginNotInitialized:
62
+ return NSLocalizedString("Plugin is not initialized.", comment: "pluginNotInitialized")
50
63
  case .signatureMissing:
51
64
  return NSLocalizedString("Bundle does not contain a signature.", comment: "signatureMissing")
52
65
  case .signatureVerificationFailed: