@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.
- package/CapawesomeCapacitorLiveUpdate.podspec +15 -0
- package/Package.swift +21 -5
- package/README.md +35 -2
- package/android/build.gradle +19 -0
- package/android/consumer-rules.pro +5 -0
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +181 -56
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java +47 -1
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/DownloadBundleOptions.java +11 -5
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/FetchLatestBundleOptions.java +23 -1
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/FetchLatestBundleResult.java +30 -0
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/providers/ionic/LiveUpdateIonicManager.kt +229 -0
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/providers/ionic/LiveUpdateIonicPreferences.kt +85 -0
- package/ios/Plugin/Classes/Options/DownloadBundleOptions.swift +7 -6
- package/ios/Plugin/Classes/Options/FetchLatestBundleOptions.swift +19 -3
- package/ios/Plugin/Classes/Results/FetchLatestBundleResult.swift +24 -0
- package/ios/Plugin/Enums/CustomError.swift +13 -0
- package/ios/Plugin/LiveUpdate.swift +140 -45
- package/ios/Plugin/LiveUpdatePlugin.swift +16 -3
- package/ios/Plugin/Providers/Ionic/LiveUpdateIonicManager.swift +187 -0
- package/ios/Plugin/Providers/Ionic/LiveUpdateIonicPreferences.swift +81 -0
- package/ios/Plugin/Providers/Ionic/LiveUpdatePlugin+LiveUpdateProvider.swift +15 -0
- package/package.json +2 -1
package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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:
|