@capawesome/cordova-live-update 0.1.0

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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1113 -0
  3. package/dist/docs.json +1654 -0
  4. package/dist/esm/definitions.d.ts +788 -0
  5. package/dist/esm/definitions.js +7 -0
  6. package/dist/esm/definitions.js.map +1 -0
  7. package/dist/esm/exec.d.ts +1 -0
  8. package/dist/esm/exec.js +8 -0
  9. package/dist/esm/exec.js.map +1 -0
  10. package/dist/esm/index.d.ts +4 -0
  11. package/dist/esm/index.js +46 -0
  12. package/dist/esm/index.js.map +1 -0
  13. package/dist/plugin.js +56 -0
  14. package/dist/plugin.js.map +1 -0
  15. package/package.json +93 -0
  16. package/plugin.xml +268 -0
  17. package/src/android/capawesome-cordova-live-update.gradle +12 -0
  18. package/src/android/capawesome-live-update.xml +5 -0
  19. package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdate.java +1480 -0
  20. package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdateConfig.java +105 -0
  21. package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdateHttpClient.java +114 -0
  22. package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdatePathHandler.java +96 -0
  23. package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdatePlugin.java +550 -0
  24. package/src/android/io/capawesome/cordova/plugins/liveupdate/LiveUpdatePreferences.java +151 -0
  25. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/Manifest.java +58 -0
  26. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/ManifestItem.java +37 -0
  27. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/api/GetChannelsResponseItem.java +28 -0
  28. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/api/GetLatestBundleResponse.java +74 -0
  29. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/events/DownloadBundleProgressEvent.java +33 -0
  30. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/events/NextBundleSetEvent.java +26 -0
  31. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/DeleteBundleOptions.java +18 -0
  32. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/DownloadBundleOptions.java +66 -0
  33. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/FetchChannelsOptions.java +39 -0
  34. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/FetchLatestBundleOptions.java +25 -0
  35. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/SetChannelOptions.java +18 -0
  36. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/SetConfigOptions.java +20 -0
  37. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/SetCustomIdOptions.java +18 -0
  38. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/SetNextBundleOptions.java +21 -0
  39. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/options/SyncOptions.java +25 -0
  40. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/ChannelResult.java +29 -0
  41. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/FetchChannelsResult.java +29 -0
  42. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/FetchLatestBundleResult.java +69 -0
  43. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetBlockedBundlesResult.java +28 -0
  44. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetBundlesResult.java +28 -0
  45. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetChannelResult.java +22 -0
  46. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetConfigResult.java +40 -0
  47. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetCurrentBundleResult.java +22 -0
  48. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetCustomIdResult.java +22 -0
  49. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetDeviceIdResult.java +22 -0
  50. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetDownloadedBundlesResult.java +28 -0
  51. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetNextBundleResult.java +22 -0
  52. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetVersionCodeResult.java +21 -0
  53. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/GetVersionNameResult.java +22 -0
  54. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/IsSyncingResult.java +27 -0
  55. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/ReadyResult.java +32 -0
  56. package/src/android/io/capawesome/cordova/plugins/liveupdate/classes/results/SyncResult.java +22 -0
  57. package/src/android/io/capawesome/cordova/plugins/liveupdate/enums/ArtifactType.java +6 -0
  58. package/src/android/io/capawesome/cordova/plugins/liveupdate/interfaces/Callback.java +5 -0
  59. package/src/android/io/capawesome/cordova/plugins/liveupdate/interfaces/DownloadProgressCallback.java +5 -0
  60. package/src/android/io/capawesome/cordova/plugins/liveupdate/interfaces/EmptyCallback.java +5 -0
  61. package/src/android/io/capawesome/cordova/plugins/liveupdate/interfaces/NonEmptyCallback.java +7 -0
  62. package/src/android/io/capawesome/cordova/plugins/liveupdate/interfaces/Result.java +8 -0
  63. package/src/ios/LiveUpdate.swift +895 -0
  64. package/src/ios/LiveUpdateArtifactType.swift +4 -0
  65. package/src/ios/LiveUpdateChannelResult.swift +18 -0
  66. package/src/ios/LiveUpdateConfig.swift +11 -0
  67. package/src/ios/LiveUpdateDeleteBundleOptions.swift +13 -0
  68. package/src/ios/LiveUpdateDownloadBundleOptions.swift +41 -0
  69. package/src/ios/LiveUpdateDownloadBundleProgressEvent.swift +24 -0
  70. package/src/ios/LiveUpdateError.swift +62 -0
  71. package/src/ios/LiveUpdateFetchChannelsOptions.swift +25 -0
  72. package/src/ios/LiveUpdateFetchChannelsResult.swift +15 -0
  73. package/src/ios/LiveUpdateFetchLatestBundleOptions.swift +17 -0
  74. package/src/ios/LiveUpdateFetchLatestBundleResult.swift +42 -0
  75. package/src/ios/LiveUpdateGetBlockedBundlesResult.swift +15 -0
  76. package/src/ios/LiveUpdateGetBundlesResult.swift +15 -0
  77. package/src/ios/LiveUpdateGetChannelResult.swift +15 -0
  78. package/src/ios/LiveUpdateGetChannelsResponseItem.swift +4 -0
  79. package/src/ios/LiveUpdateGetConfigResult.swift +18 -0
  80. package/src/ios/LiveUpdateGetCurrentBundleResult.swift +15 -0
  81. package/src/ios/LiveUpdateGetCustomIdResult.swift +15 -0
  82. package/src/ios/LiveUpdateGetDeviceIdResult.swift +15 -0
  83. package/src/ios/LiveUpdateGetDownloadedBundlesResult.swift +15 -0
  84. package/src/ios/LiveUpdateGetLatestBundleResponse.swift +8 -0
  85. package/src/ios/LiveUpdateGetNextBundleResult.swift +15 -0
  86. package/src/ios/LiveUpdateGetVersionCodeResult.swift +15 -0
  87. package/src/ios/LiveUpdateGetVersionNameResult.swift +15 -0
  88. package/src/ios/LiveUpdateHttpClient.swift +58 -0
  89. package/src/ios/LiveUpdateIsSyncingResult.swift +15 -0
  90. package/src/ios/LiveUpdateManifest.swift +19 -0
  91. package/src/ios/LiveUpdateManifestItem.swift +5 -0
  92. package/src/ios/LiveUpdateNextBundleSetEvent.swift +15 -0
  93. package/src/ios/LiveUpdatePlugin.swift +521 -0
  94. package/src/ios/LiveUpdatePreferences.swift +116 -0
  95. package/src/ios/LiveUpdateReadyResult.swift +21 -0
  96. package/src/ios/LiveUpdateResult.swift +5 -0
  97. package/src/ios/LiveUpdateSchemeHandler.swift +286 -0
  98. package/src/ios/LiveUpdateSetChannelOptions.swift +13 -0
  99. package/src/ios/LiveUpdateSetConfigOptions.swift +13 -0
  100. package/src/ios/LiveUpdateSetCustomIdOptions.swift +13 -0
  101. package/src/ios/LiveUpdateSetNextBundleOptions.swift +13 -0
  102. package/src/ios/LiveUpdateSyncOptions.swift +17 -0
  103. package/src/ios/LiveUpdateSyncResult.swift +15 -0
@@ -0,0 +1,1480 @@
1
+ package io.capawesome.cordova.plugins.liveupdate;
2
+
3
+ import android.content.pm.PackageInfo;
4
+ import android.content.pm.PackageManager;
5
+ import android.content.res.AssetManager;
6
+ import android.os.Build;
7
+ import android.os.Handler;
8
+ import android.os.Looper;
9
+ import android.util.Base64;
10
+ import android.util.Log;
11
+ import androidx.annotation.NonNull;
12
+ import androidx.annotation.Nullable;
13
+ import io.capawesome.cordova.plugins.liveupdate.classes.Manifest;
14
+ import io.capawesome.cordova.plugins.liveupdate.classes.ManifestItem;
15
+ import io.capawesome.cordova.plugins.liveupdate.classes.api.GetChannelsResponseItem;
16
+ import io.capawesome.cordova.plugins.liveupdate.classes.api.GetLatestBundleResponse;
17
+ import io.capawesome.cordova.plugins.liveupdate.classes.events.DownloadBundleProgressEvent;
18
+ import io.capawesome.cordova.plugins.liveupdate.classes.events.NextBundleSetEvent;
19
+ import io.capawesome.cordova.plugins.liveupdate.classes.options.DeleteBundleOptions;
20
+ import io.capawesome.cordova.plugins.liveupdate.classes.options.DownloadBundleOptions;
21
+ import io.capawesome.cordova.plugins.liveupdate.classes.options.FetchChannelsOptions;
22
+ import io.capawesome.cordova.plugins.liveupdate.classes.options.FetchLatestBundleOptions;
23
+ import io.capawesome.cordova.plugins.liveupdate.classes.options.SetChannelOptions;
24
+ import io.capawesome.cordova.plugins.liveupdate.classes.options.SetConfigOptions;
25
+ import io.capawesome.cordova.plugins.liveupdate.classes.options.SetCustomIdOptions;
26
+ import io.capawesome.cordova.plugins.liveupdate.classes.options.SetNextBundleOptions;
27
+ import io.capawesome.cordova.plugins.liveupdate.classes.options.SyncOptions;
28
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.ChannelResult;
29
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.FetchChannelsResult;
30
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.FetchLatestBundleResult;
31
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.GetBlockedBundlesResult;
32
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.GetBundlesResult;
33
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.GetChannelResult;
34
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.GetConfigResult;
35
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.GetCurrentBundleResult;
36
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.GetCustomIdResult;
37
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.GetDeviceIdResult;
38
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.GetDownloadedBundlesResult;
39
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.GetNextBundleResult;
40
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.GetVersionCodeResult;
41
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.GetVersionNameResult;
42
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.IsSyncingResult;
43
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.ReadyResult;
44
+ import io.capawesome.cordova.plugins.liveupdate.classes.results.SyncResult;
45
+ import io.capawesome.cordova.plugins.liveupdate.enums.ArtifactType;
46
+ import io.capawesome.cordova.plugins.liveupdate.interfaces.DownloadProgressCallback;
47
+ import io.capawesome.cordova.plugins.liveupdate.interfaces.EmptyCallback;
48
+ import io.capawesome.cordova.plugins.liveupdate.interfaces.NonEmptyCallback;
49
+ import io.capawesome.cordova.plugins.liveupdate.interfaces.Result;
50
+ import java.io.File;
51
+ import java.io.FileInputStream;
52
+ import java.io.FileOutputStream;
53
+ import java.io.IOException;
54
+ import java.io.InputStream;
55
+ import java.security.KeyFactory;
56
+ import java.security.MessageDigest;
57
+ import java.security.PublicKey;
58
+ import java.security.Signature;
59
+ import java.security.spec.X509EncodedKeySpec;
60
+ import java.util.ArrayList;
61
+ import java.util.Arrays;
62
+ import java.util.List;
63
+ import java.util.UUID;
64
+ import java.util.concurrent.CountDownLatch;
65
+ import java.util.concurrent.atomic.AtomicBoolean;
66
+ import java.util.concurrent.atomic.AtomicLong;
67
+ import java.util.concurrent.atomic.AtomicReference;
68
+ import net.lingala.zip4j.ZipFile;
69
+ import net.lingala.zip4j.model.FileHeader;
70
+ import okhttp3.Call;
71
+ import okhttp3.HttpUrl;
72
+ import okhttp3.Response;
73
+ import okhttp3.ResponseBody;
74
+ import okio.Buffer;
75
+ import okio.BufferedSource;
76
+ import okio.Okio;
77
+ import org.json.JSONArray;
78
+ import org.json.JSONObject;
79
+
80
+ public class LiveUpdate {
81
+
82
+ private final long autoUpdateIntervalMs = 15 * 60 * 1000; // 15 minutes
83
+
84
+ @NonNull
85
+ private final LiveUpdateConfig config;
86
+
87
+ /**
88
+ * Directory inside the APK where the bundled web assets live. Cordova builds
89
+ * place {@code www/} at the root of the APK assets folder.
90
+ */
91
+ private final String defaultWebAssetDir = "www";
92
+
93
+ @NonNull
94
+ private final LiveUpdateHttpClient httpClient;
95
+
96
+ @NonNull
97
+ private final LiveUpdatePlugin plugin;
98
+
99
+ @NonNull
100
+ private final LiveUpdatePathHandler pathHandler;
101
+
102
+ @NonNull
103
+ private final LiveUpdatePreferences preferences;
104
+
105
+ private final String bundlesDirectory = "capawesome_live_update_bundles"; // DO NOT CHANGE!
106
+ private final Handler rollbackHandler = new Handler(Looper.getMainLooper());
107
+ private final String manifestFileName = "capawesome-live-update-manifest.json"; // DO NOT CHANGE!
108
+
109
+ private long lastAutoUpdateCheckTimestamp = 0;
110
+ private boolean rollbackPerformed = false;
111
+ private boolean syncInProgress = false;
112
+
113
+ public LiveUpdate(@NonNull LiveUpdateConfig config, @NonNull LiveUpdatePlugin plugin, @NonNull LiveUpdatePathHandler pathHandler)
114
+ throws PackageManager.NameNotFoundException {
115
+ this.config = config;
116
+ this.httpClient = new LiveUpdateHttpClient(config);
117
+ this.plugin = plugin;
118
+ this.pathHandler = pathHandler;
119
+ this.preferences = new LiveUpdatePreferences(plugin.getContext());
120
+
121
+ // Check version and reset config (and the next bundle) if the native app
122
+ // version changed. This must run before promoting the next bundle below so
123
+ // that a stale, now-incompatible bundle is not activated.
124
+ checkAndResetConfigIfVersionChanged();
125
+
126
+ // Promote the persisted next bundle to active for this session (matches
127
+ // Capacitor's launch-time behavior, where the persisted next path becomes
128
+ // the current server base path on each cold start).
129
+ String nextBundleId = preferences.getNextBundleId();
130
+ if (nextBundleId != null && hasBundleById(nextBundleId)) {
131
+ pathHandler.setActiveBundleDir(buildBundleDirectoryFor(nextBundleId));
132
+ }
133
+
134
+ // Set the device ID on the HTTP client (after any potential config reset)
135
+ this.httpClient.setDeviceId(getDeviceId());
136
+
137
+ // Start the rollback timer to rollback to the default bundle
138
+ // if the app is not ready after a certain time
139
+ startRollbackTimer();
140
+
141
+ // Trigger an initial auto-update if configured. Unlike Capacitor we do not
142
+ // need to wait for the WebView's local server to come up; we use OkHttp
143
+ // directly and the path handler is already in place.
144
+ if ("background".equals(config.getAutoUpdateStrategy())) {
145
+ performAutoUpdate();
146
+ }
147
+ }
148
+
149
+ public void clearBlockedBundles() {
150
+ preferences.setBlockedBundleIds(null);
151
+ }
152
+
153
+ public void deleteBundle(@NonNull DeleteBundleOptions options, @NonNull EmptyCallback callback) {
154
+ String bundleId = options.getBundleId();
155
+
156
+ if (!hasBundleById(bundleId)) {
157
+ Exception exception = new Exception(LiveUpdatePlugin.ERROR_BUNDLE_NOT_FOUND);
158
+ callback.error(exception);
159
+ return;
160
+ }
161
+ deleteBundleById(bundleId);
162
+
163
+ callback.success();
164
+ }
165
+
166
+ public void downloadBundle(@NonNull DownloadBundleOptions options, @NonNull EmptyCallback callback) {
167
+ ArtifactType artifactType = options.getArtifactType();
168
+ String bundleId = options.getBundleId();
169
+ String checksum = options.getChecksum();
170
+ String signature = options.getSignature();
171
+ String url = options.getUrl();
172
+
173
+ // Check if the bundle already exists
174
+ if (hasBundleById(bundleId)) {
175
+ Exception exception = new Exception(LiveUpdatePlugin.ERROR_BUNDLE_EXISTS);
176
+ callback.error(exception);
177
+ return;
178
+ }
179
+
180
+ // Download the bundle
181
+ if (artifactType == ArtifactType.MANIFEST) {
182
+ downloadBundleOfTypeManifest(bundleId, url, callback);
183
+ } else {
184
+ downloadBundleOfTypeZip(bundleId, checksum, signature, url, callback);
185
+ }
186
+ }
187
+
188
+ public void fetchChannels(@NonNull FetchChannelsOptions options, @NonNull NonEmptyCallback<FetchChannelsResult> callback) {
189
+ try {
190
+ HttpUrl.Builder urlBuilder = new HttpUrl.Builder()
191
+ .scheme("https")
192
+ .host(config.getServerDomain())
193
+ .addPathSegment("v1")
194
+ .addPathSegment("apps")
195
+ .addPathSegment(getAppId())
196
+ .addPathSegment("channels");
197
+ if (options.getLimit() != null) {
198
+ urlBuilder.addQueryParameter("limit", String.valueOf(options.getLimit()));
199
+ }
200
+ if (options.getOffset() != null) {
201
+ urlBuilder.addQueryParameter("offset", String.valueOf(options.getOffset()));
202
+ }
203
+ if (options.getQuery() != null) {
204
+ urlBuilder.addQueryParameter("query", options.getQuery());
205
+ }
206
+ String url = urlBuilder.build().toString();
207
+
208
+ httpClient.enqueue(
209
+ url,
210
+ new NonEmptyCallback<Response>() {
211
+ @Override
212
+ public void success(@NonNull Response response) {
213
+ try {
214
+ String responseBodyString = response.body().string();
215
+ if (response.isSuccessful()) {
216
+ JSONArray responseJsonArray = new JSONArray(responseBodyString);
217
+ ChannelResult[] channels = new ChannelResult[responseJsonArray.length()];
218
+ for (int i = 0; i < responseJsonArray.length(); i++) {
219
+ GetChannelsResponseItem item = new GetChannelsResponseItem(responseJsonArray.getJSONObject(i));
220
+ channels[i] = new ChannelResult(item.getId(), item.getName());
221
+ }
222
+ FetchChannelsResult result = new FetchChannelsResult(channels);
223
+ callback.success(result);
224
+ } else if (response.code() == 401) {
225
+ callback.error(new Exception("Unauthorized. Channel Discovery may not be enabled for this app."));
226
+ } else {
227
+ callback.error(new Exception(responseBodyString));
228
+ }
229
+ } catch (Exception e) {
230
+ callback.error(e);
231
+ }
232
+ }
233
+
234
+ @Override
235
+ public void error(@NonNull Exception exception) {
236
+ callback.error(exception);
237
+ }
238
+ }
239
+ );
240
+ } catch (Exception e) {
241
+ callback.error(e);
242
+ }
243
+ }
244
+
245
+ public void fetchLatestBundle(@NonNull FetchLatestBundleOptions options, @NonNull NonEmptyCallback<FetchLatestBundleResult> callback) {
246
+ fetchLatestBundleInternal(
247
+ options,
248
+ new NonEmptyCallback<GetLatestBundleResponse>() {
249
+ @Override
250
+ public void success(@Nullable GetLatestBundleResponse response) {
251
+ ArtifactType artifactType = response == null ? null : response.getArtifactType();
252
+ String bundleId = response == null ? null : response.getBundleId();
253
+ String checksum = response == null ? null : response.getChecksum();
254
+ JSONObject customProperties = response == null ? null : response.getCustomProperties();
255
+ String downloadUrl = response == null ? null : response.getUrl();
256
+ String signature = response == null ? null : response.getSignature();
257
+ FetchLatestBundleResult result = new FetchLatestBundleResult(
258
+ artifactType,
259
+ bundleId,
260
+ checksum,
261
+ customProperties,
262
+ downloadUrl,
263
+ signature
264
+ );
265
+ callback.success(result);
266
+ }
267
+
268
+ @Override
269
+ public void error(@NonNull Exception exception) {
270
+ callback.error(exception);
271
+ }
272
+ }
273
+ );
274
+ }
275
+
276
+ public void getBlockedBundles(@NonNull NonEmptyCallback<GetBlockedBundlesResult> callback) {
277
+ String blockedIds = preferences.getBlockedBundleIds();
278
+ String[] bundleIds;
279
+ if (blockedIds == null || blockedIds.isEmpty()) {
280
+ bundleIds = new String[0];
281
+ } else {
282
+ bundleIds = blockedIds.split(",");
283
+ }
284
+ GetBlockedBundlesResult result = new GetBlockedBundlesResult(bundleIds);
285
+ callback.success(result);
286
+ }
287
+
288
+ public void getBundles(@NonNull NonEmptyCallback callback) {
289
+ String[] bundleIds = getDownloadedBundleIds();
290
+ GetBundlesResult result = new GetBundlesResult(bundleIds);
291
+ callback.success(result);
292
+ }
293
+
294
+ public void getDownloadedBundles(@NonNull NonEmptyCallback<GetDownloadedBundlesResult> callback) {
295
+ String[] bundleIds = getDownloadedBundleIds();
296
+ GetDownloadedBundlesResult result = new GetDownloadedBundlesResult(bundleIds);
297
+ callback.success(result);
298
+ }
299
+
300
+ public void getChannel(@NonNull NonEmptyCallback callback) {
301
+ String channel = getChannel();
302
+ GetChannelResult result = new GetChannelResult(channel);
303
+ callback.success(result);
304
+ }
305
+
306
+ public void getConfig(@NonNull NonEmptyCallback<GetConfigResult> callback) {
307
+ String appId = getAppId();
308
+ String autoUpdateStrategy = config.getAutoUpdateStrategy();
309
+ GetConfigResult result = new GetConfigResult(appId, autoUpdateStrategy);
310
+ callback.success(result);
311
+ }
312
+
313
+ public void getCurrentBundle(@NonNull NonEmptyCallback<GetCurrentBundleResult> callback) {
314
+ String bundleId = getCurrentBundleId();
315
+ GetCurrentBundleResult result = new GetCurrentBundleResult(bundleId);
316
+ callback.success(result);
317
+ }
318
+
319
+ public void getCustomId(@NonNull NonEmptyCallback callback) {
320
+ String customId = preferences.getCustomId();
321
+ GetCustomIdResult result = new GetCustomIdResult(customId);
322
+ callback.success(result);
323
+ }
324
+
325
+ public void getDeviceId(@NonNull NonEmptyCallback callback) {
326
+ String deviceId = getDeviceId();
327
+ GetDeviceIdResult result = new GetDeviceIdResult(deviceId);
328
+ callback.success(result);
329
+ }
330
+
331
+ public void getNextBundle(@NonNull NonEmptyCallback<GetNextBundleResult> callback) {
332
+ String bundleId = getNextBundleId();
333
+ GetNextBundleResult result = new GetNextBundleResult(bundleId);
334
+ callback.success(result);
335
+ }
336
+
337
+ public void getVersionCode(@NonNull NonEmptyCallback callback) throws PackageManager.NameNotFoundException {
338
+ String versionCode = getVersionCodeAsString();
339
+ GetVersionCodeResult result = new GetVersionCodeResult(versionCode);
340
+ callback.success(result);
341
+ }
342
+
343
+ public void getVersionName(@NonNull NonEmptyCallback callback) throws PackageManager.NameNotFoundException {
344
+ String versionName = getVersionName();
345
+ GetVersionNameResult result = new GetVersionNameResult(versionName);
346
+ callback.success(result);
347
+ }
348
+
349
+ public void isSyncing(@NonNull NonEmptyCallback<IsSyncingResult> callback) {
350
+ IsSyncingResult result = new IsSyncingResult(syncInProgress);
351
+ callback.success(result);
352
+ }
353
+
354
+ public void handleOnResume() {
355
+ if ("background".equals(config.getAutoUpdateStrategy())) {
356
+ performAutoUpdate();
357
+ }
358
+ }
359
+
360
+ public void ready(@NonNull NonEmptyCallback callback) {
361
+ Log.d(LiveUpdatePlugin.TAG, "App is ready.");
362
+ if (config.getReadyTimeout() <= 0) {
363
+ Log.w(LiveUpdatePlugin.TAG, "Ready timeout is set to 0. Automatic rollback is disabled.");
364
+ }
365
+ // Stop the rollback timer
366
+ stopRollbackTimer();
367
+ // Delete unused bundles
368
+ if (config.getAutoDeleteBundles()) {
369
+ deleteUnusedBundles();
370
+ }
371
+ // Get the current and previous bundle IDs
372
+ String currentBundleId = getCurrentBundleId();
373
+ String previousBundleId = getPreviousBundleId();
374
+ // Block the rolled back bundle if enabled
375
+ if (config.getAutoBlockRolledBackBundles() && rollbackPerformed && previousBundleId != null) {
376
+ addBlockedBundleId(previousBundleId);
377
+ }
378
+ // Return the result
379
+ ReadyResult result = new ReadyResult(currentBundleId, previousBundleId, rollbackPerformed);
380
+ callback.success(result);
381
+ // Set the new previous bundle ID
382
+ setPreviousBundleId(currentBundleId);
383
+ // Reset the rollback flag
384
+ rollbackPerformed = false;
385
+ }
386
+
387
+ public void reload() {
388
+ String nextBundleId = getNextBundleId();
389
+ setCurrentBundleById(nextBundleId);
390
+ startRollbackTimer();
391
+ }
392
+
393
+ public void reset() {
394
+ setNextBundleById(null);
395
+ }
396
+
397
+ public void resetConfig() {
398
+ preferences.setAppId(null);
399
+ }
400
+
401
+ public void setChannel(@NonNull SetChannelOptions options, @NonNull EmptyCallback callback) {
402
+ String channel = options.getChannel();
403
+
404
+ preferences.setChannel(channel);
405
+ callback.success();
406
+ }
407
+
408
+ public void setConfig(@NonNull SetConfigOptions options) {
409
+ String appId = options.getAppId();
410
+ preferences.setAppId(appId);
411
+ }
412
+
413
+ public void setCustomId(@NonNull SetCustomIdOptions options, @NonNull EmptyCallback callback) {
414
+ String customId = options.getCustomId();
415
+
416
+ preferences.setCustomId(customId);
417
+ callback.success();
418
+ }
419
+
420
+ public void setNextBundle(@NonNull SetNextBundleOptions options, @NonNull EmptyCallback callback) {
421
+ String bundleId = options.getBundleId();
422
+
423
+ if (bundleId == null) {
424
+ reset();
425
+ } else {
426
+ if (hasBundleById(bundleId)) {
427
+ setNextBundleById(bundleId);
428
+ } else {
429
+ Exception exception = new Exception(LiveUpdatePlugin.ERROR_BUNDLE_NOT_FOUND);
430
+ callback.error(exception);
431
+ return;
432
+ }
433
+ }
434
+ callback.success();
435
+ }
436
+
437
+ public void sync(@NonNull SyncOptions options, @NonNull NonEmptyCallback<Result> callback) {
438
+ if (syncInProgress) {
439
+ Exception exception = new Exception(LiveUpdatePlugin.ERROR_SYNC_IN_PROGRESS);
440
+ callback.error(exception);
441
+ return;
442
+ }
443
+ syncInProgress = true;
444
+
445
+ String channel = options.getChannel();
446
+ // Fetch the latest bundle
447
+ FetchLatestBundleOptions fetchLatestBundleOptions = new FetchLatestBundleOptions(channel);
448
+ fetchLatestBundleInternal(
449
+ fetchLatestBundleOptions,
450
+ new NonEmptyCallback<GetLatestBundleResponse>() {
451
+ @Override
452
+ public void success(@Nullable GetLatestBundleResponse response) {
453
+ try {
454
+ if (response == null) {
455
+ Log.d(LiveUpdatePlugin.TAG, "No update available.");
456
+ syncInProgress = false;
457
+ SyncResult syncResult = new SyncResult(null);
458
+ callback.success(syncResult);
459
+ return;
460
+ }
461
+ ArtifactType artifactType = response.getArtifactType();
462
+ String latestBundleId = response.getBundleId();
463
+ String checksum = response.getChecksum();
464
+ String signature = response.getSignature();
465
+ String url = response.getUrl();
466
+ // Check if the bundle is blocked
467
+ if (isBlockedBundleId(latestBundleId)) {
468
+ Log.w(LiveUpdatePlugin.TAG, "Bundle is blocked and will not be downloaded.");
469
+ syncInProgress = false;
470
+ SyncResult syncResult = new SyncResult(null);
471
+ callback.success(syncResult);
472
+ return;
473
+ }
474
+ // Check if the bundle already exists
475
+ if (hasBundleById(latestBundleId)) {
476
+ String nextBundleId = null;
477
+ String currentBundleId = getCurrentBundleId();
478
+ if (!latestBundleId.equals(currentBundleId)) {
479
+ // Set the next bundle
480
+ setNextBundleById(latestBundleId);
481
+ nextBundleId = latestBundleId;
482
+ }
483
+ syncInProgress = false;
484
+ SyncResult syncResult = new SyncResult(nextBundleId);
485
+ callback.success(syncResult);
486
+ return;
487
+ }
488
+
489
+ // Download the bundle
490
+ EmptyCallback downloadCallback = new EmptyCallback() {
491
+ @Override
492
+ public void success() {
493
+ try {
494
+ // Set the next bundle
495
+ setNextBundleById(latestBundleId);
496
+ syncInProgress = false;
497
+ SyncResult syncResult = new SyncResult(latestBundleId);
498
+ callback.success(syncResult);
499
+ } catch (Exception e) {
500
+ syncInProgress = false;
501
+ callback.error(e);
502
+ }
503
+ }
504
+
505
+ @Override
506
+ public void error(@NonNull Exception exception) {
507
+ syncInProgress = false;
508
+ callback.error(exception);
509
+ }
510
+ };
511
+
512
+ if (artifactType == ArtifactType.MANIFEST) {
513
+ downloadBundleOfTypeManifest(latestBundleId, url, downloadCallback);
514
+ } else {
515
+ downloadBundleOfTypeZip(latestBundleId, checksum, signature, url, downloadCallback);
516
+ }
517
+ } catch (Exception e) {
518
+ syncInProgress = false;
519
+ callback.error(e);
520
+ }
521
+ }
522
+
523
+ @Override
524
+ public void error(@NonNull Exception exception) {
525
+ syncInProgress = false;
526
+ callback.error(exception);
527
+ }
528
+ }
529
+ );
530
+ }
531
+
532
+ private void addBundle(@NonNull String bundleId, @NonNull File sourceDirectory) throws Exception {
533
+ // Search folder with index.html file
534
+ File indexHtmlFile = searchIndexHtmlFile(sourceDirectory);
535
+ if (indexHtmlFile == null) {
536
+ throw new Exception(LiveUpdatePlugin.ERROR_BUNDLE_INDEX_HTML_MISSING);
537
+ }
538
+
539
+ // Create the bundles directory if it does not exist
540
+ createBundlesDirectory();
541
+
542
+ // Move the bundle directory to the bundles directory
543
+ File bundleDirectory = buildBundleDirectoryFor(bundleId);
544
+ indexHtmlFile.getParentFile().renameTo(bundleDirectory);
545
+ }
546
+
547
+ private void addBundleOfTypeManifest(@NonNull String bundleId, @NonNull File directory) throws Exception {
548
+ addBundle(bundleId, directory);
549
+ }
550
+
551
+ private void addBundleOfTypeZip(@NonNull String bundleId, @NonNull File zipFile) throws Exception {
552
+ // Unzip the file to the bundle directory
553
+ File unzippedDirectory = unzipFile(zipFile);
554
+ // Add the bundle
555
+ addBundle(bundleId, unzippedDirectory);
556
+ }
557
+
558
+ private File buildBundlesDirectory() {
559
+ return new File(plugin.getContext().getFilesDir(), bundlesDirectory);
560
+ }
561
+
562
+ private File buildBundleDirectoryFor(@NonNull String bundleId) {
563
+ return new File(plugin.getContext().getFilesDir(), bundlesDirectory + "/" + bundleId);
564
+ }
565
+
566
+ private File buildTemporaryDirectory() {
567
+ String fileName = UUID.randomUUID().toString();
568
+ return new File(plugin.getContext().getCacheDir(), fileName);
569
+ }
570
+
571
+ private File buildTemporaryZipFile() {
572
+ String fileName = UUID.randomUUID().toString() + ".zip";
573
+ return new File(plugin.getContext().getCacheDir(), fileName);
574
+ }
575
+
576
+ private void copyCurrentBundleFile(@NonNull ManifestItem fileToCopy, @NonNull File destinationDirectory) throws IOException {
577
+ String href = fileToCopy.getHref();
578
+ String currentBundleId = getCurrentBundleId();
579
+ if (currentBundleId == null) {
580
+ // Create the source input stream
581
+ AssetManager assets = plugin.getContext().getAssets();
582
+ InputStream inputStream = assets.open(defaultWebAssetDir + "/" + href);
583
+ // Create the destination file
584
+ File destination = new File(destinationDirectory, href);
585
+ // Create all destination directories if they do not exist
586
+ destination.getParentFile().mkdirs();
587
+ // Copy the file
588
+ copyFile(inputStream, destination);
589
+ } else {
590
+ File currentBundleDirectory = buildBundleDirectoryFor(currentBundleId);
591
+ // Create the source and destination files
592
+ File source = new File(currentBundleDirectory, href);
593
+ File destination = new File(destinationDirectory, href);
594
+ // Create all destination directories if they do not exist
595
+ destination.getParentFile().mkdirs();
596
+ // Copy the file
597
+ copyFile(source, destination);
598
+ }
599
+ }
600
+
601
+ private List<ManifestItem> copyCurrentBundleFilesAndReturnFailures(
602
+ @NonNull List<ManifestItem> filesToCopy,
603
+ @NonNull File destinationDirectory
604
+ ) {
605
+ List<ManifestItem> missingItems = new ArrayList<>();
606
+ for (ManifestItem fileToCopy : filesToCopy) {
607
+ boolean success = tryCopyCurrentBundleFile(fileToCopy, destinationDirectory);
608
+ if (!success) {
609
+ Log.w(LiveUpdatePlugin.TAG, "Failed to copy file: " + fileToCopy.getHref());
610
+ // If the file could not be copied, add it to the list of missing items
611
+ missingItems.add(fileToCopy);
612
+ }
613
+ }
614
+ return missingItems;
615
+ }
616
+
617
+ private void copyFile(File input, File output) throws IOException {
618
+ FileInputStream in = new FileInputStream(input);
619
+ FileOutputStream out = new FileOutputStream(output);
620
+ byte[] buffer = new byte[1024];
621
+ int length;
622
+ while ((length = in.read(buffer)) > 0) {
623
+ out.write(buffer, 0, length);
624
+ }
625
+ out.close();
626
+ in.close();
627
+ }
628
+
629
+ private void copyFile(InputStream input, File output) throws IOException {
630
+ FileOutputStream out = new FileOutputStream(output);
631
+ byte[] buffer = new byte[1024];
632
+ int length;
633
+ while ((length = input.read(buffer)) > 0) {
634
+ out.write(buffer, 0, length);
635
+ }
636
+ out.close();
637
+ input.close();
638
+ }
639
+
640
+ private void createBundlesDirectory() {
641
+ File file = buildBundlesDirectory();
642
+ if (!file.exists()) {
643
+ file.mkdir();
644
+ }
645
+ }
646
+
647
+ private PublicKey createPublicKeyFromString(@NonNull String value) throws Exception {
648
+ try {
649
+ value = value.replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", "").replace("\n", "");
650
+ byte[] byteKey = Base64.decode(value, Base64.DEFAULT);
651
+ X509EncodedKeySpec X509publicKey = new X509EncodedKeySpec(byteKey);
652
+ KeyFactory keyFactory = KeyFactory.getInstance("RSA");
653
+ return keyFactory.generatePublic(X509publicKey);
654
+ } catch (Exception exception) {
655
+ Log.e(LiveUpdatePlugin.TAG, exception.getMessage(), exception);
656
+ throw new Exception(LiveUpdatePlugin.ERROR_PUBLIC_KEY_INVALID);
657
+ }
658
+ }
659
+
660
+ private File createTemporaryDirectory() {
661
+ File file = buildTemporaryDirectory();
662
+ file.mkdir();
663
+ return file;
664
+ }
665
+
666
+ private void deleteBundleById(@NonNull String bundleId) {
667
+ // Delete the bundle directory
668
+ File bundleDirectory = buildBundleDirectoryFor(bundleId);
669
+ deleteFileRecursively(bundleDirectory);
670
+ // Reset the next bundle if it is the deleted bundle
671
+ String nextBundleId = getNextBundleId();
672
+ if (bundleId.equals(nextBundleId)) {
673
+ setNextBundleById(null);
674
+ }
675
+ }
676
+
677
+ private void deleteFileRecursively(@NonNull File file) {
678
+ if (file.isDirectory()) {
679
+ File[] children = file.listFiles();
680
+ if (children != null) {
681
+ for (File child : children) {
682
+ deleteFileRecursively(child);
683
+ }
684
+ }
685
+ }
686
+ file.delete();
687
+ }
688
+
689
+ private void deleteUnusedBundles() {
690
+ String[] bundleIds = getDownloadedBundleIds();
691
+ for (String bundleId : bundleIds) {
692
+ if (!isBundleInUse(bundleId)) {
693
+ deleteBundleById(bundleId);
694
+ }
695
+ }
696
+ }
697
+
698
+ private Call downloadAndVerifyFile(
699
+ @NonNull String url,
700
+ @NonNull File file,
701
+ @Nullable String checksum,
702
+ @Nullable String signature,
703
+ @Nullable DownloadProgressCallback progressCallback,
704
+ @NonNull EmptyCallback completionCallback
705
+ ) {
706
+ return httpClient.enqueue(
707
+ url,
708
+ new NonEmptyCallback<Response>() {
709
+ @Override
710
+ public void success(@NonNull Response response) {
711
+ try {
712
+ if (response.isSuccessful()) {
713
+ ResponseBody responseBody = response.body();
714
+ LiveUpdateHttpClient.writeResponseBodyToFile(responseBody, file, progressCallback);
715
+
716
+ // Extract checksum/signature from headers
717
+ String finalChecksum = checksum == null ? LiveUpdateHttpClient.getChecksumFromResponse(response) : checksum;
718
+ String finalSignature = signature == null ? LiveUpdateHttpClient.getSignatureFromResponse(response) : signature;
719
+
720
+ // Verify file
721
+ verifyFile(file, finalChecksum, finalSignature);
722
+ completionCallback.success();
723
+ } else {
724
+ String errorMessage = response.body().string();
725
+ Exception exception = new Exception(LiveUpdatePlugin.ERROR_DOWNLOAD_FAILED);
726
+ Log.e(LiveUpdatePlugin.TAG, errorMessage, exception);
727
+ completionCallback.error(exception);
728
+ }
729
+ } catch (Exception e) {
730
+ completionCallback.error(e);
731
+ }
732
+ }
733
+
734
+ @Override
735
+ public void error(@NonNull Exception exception) {
736
+ completionCallback.error(exception);
737
+ }
738
+ }
739
+ );
740
+ }
741
+
742
+ private Call downloadBundleFile(
743
+ @NonNull String baseUrl,
744
+ @NonNull String href,
745
+ @NonNull File destinationDirectory,
746
+ @Nullable DownloadProgressCallback progressCallback,
747
+ @NonNull EmptyCallback completionCallback
748
+ ) {
749
+ HttpUrl.Builder urlBuilder = HttpUrl.parse(baseUrl).newBuilder();
750
+ urlBuilder.addQueryParameter("href", href);
751
+ String url = urlBuilder.build().toString();
752
+
753
+ File destinationFile = new File(destinationDirectory, href);
754
+ destinationFile.getParentFile().mkdirs();
755
+ return downloadAndVerifyFile(url, destinationFile, null, null, progressCallback, completionCallback);
756
+ }
757
+
758
+ private void downloadBundleFiles(
759
+ @NonNull String baseUrl,
760
+ @NonNull List<ManifestItem> filesToDownload,
761
+ @NonNull File destinationDirectory,
762
+ @Nullable DownloadProgressCallback progressCallback,
763
+ @NonNull EmptyCallback completionCallback
764
+ ) {
765
+ if (filesToDownload.isEmpty()) {
766
+ if (progressCallback != null) {
767
+ progressCallback.onProgress(0, 0);
768
+ }
769
+ completionCallback.success();
770
+ return;
771
+ }
772
+
773
+ // Thread-safe progress tracking
774
+ AtomicLong totalBytesDownloaded = new AtomicLong(0);
775
+ long totalBytesToDownload = 0;
776
+ for (ManifestItem item : filesToDownload) {
777
+ totalBytesToDownload += item.getSizeInBytes();
778
+ }
779
+ final long finalTotalBytesToDownload = totalBytesToDownload;
780
+
781
+ // Coordination primitives
782
+ CountDownLatch latch = new CountDownLatch(filesToDownload.size());
783
+ AtomicReference<Exception> firstError = new AtomicReference<>();
784
+ AtomicBoolean completionHandled = new AtomicBoolean(false);
785
+ List<Call> activeCalls = new ArrayList<>();
786
+
787
+ // Start all downloads asynchronously (OkHttp's dispatcher handles parallelization)
788
+ for (ManifestItem item : filesToDownload) {
789
+ Call call = downloadBundleFile(
790
+ baseUrl,
791
+ item.getHref(),
792
+ destinationDirectory,
793
+ (downloadedBytes, totalBytes) -> {
794
+ // Per-file progress
795
+ if (progressCallback != null) {
796
+ long totalProgress = totalBytesDownloaded.get() + downloadedBytes;
797
+ progressCallback.onProgress(totalProgress, finalTotalBytesToDownload);
798
+ }
799
+ },
800
+ new EmptyCallback() {
801
+ @Override
802
+ public void success() {
803
+ // Update total progress
804
+ totalBytesDownloaded.addAndGet(item.getSizeInBytes());
805
+ if (progressCallback != null) {
806
+ progressCallback.onProgress(totalBytesDownloaded.get(), finalTotalBytesToDownload);
807
+ }
808
+ latch.countDown();
809
+
810
+ // Check if this was the last download to complete
811
+ if (latch.getCount() == 0 && completionHandled.compareAndSet(false, true)) {
812
+ Exception error = firstError.get();
813
+ if (error != null) {
814
+ completionCallback.error(error);
815
+ } else {
816
+ // Final progress update
817
+ if (progressCallback != null) {
818
+ progressCallback.onProgress(finalTotalBytesToDownload, finalTotalBytesToDownload);
819
+ }
820
+ completionCallback.success();
821
+ }
822
+ }
823
+ }
824
+
825
+ @Override
826
+ public void error(@NonNull Exception e) {
827
+ // Capture first error and cancel all remaining downloads
828
+ if (firstError.compareAndSet(null, e)) {
829
+ Log.e(LiveUpdatePlugin.TAG, "Failed to download file: " + item.getHref(), e);
830
+ // Cancel all in-flight downloads (fail-fast)
831
+ synchronized (activeCalls) {
832
+ for (Call activeCall : activeCalls) {
833
+ if (!activeCall.isCanceled() && !activeCall.isExecuted()) {
834
+ activeCall.cancel();
835
+ }
836
+ }
837
+ }
838
+ }
839
+ latch.countDown();
840
+
841
+ // Check if this was the last download to complete
842
+ if (latch.getCount() == 0 && completionHandled.compareAndSet(false, true)) {
843
+ completionCallback.error(firstError.get());
844
+ }
845
+ }
846
+ }
847
+ );
848
+ synchronized (activeCalls) {
849
+ activeCalls.add(call);
850
+ }
851
+ }
852
+ }
853
+
854
+ private void downloadBundleOfTypeManifest(
855
+ @NonNull String bundleId,
856
+ @NonNull String downloadUrl,
857
+ @NonNull EmptyCallback completionCallback
858
+ ) {
859
+ try {
860
+ // Create a temporary directory
861
+ File temporaryDirectory = createTemporaryDirectory();
862
+
863
+ // Download the latest manifest
864
+ downloadBundleFile(
865
+ downloadUrl,
866
+ manifestFileName,
867
+ temporaryDirectory,
868
+ null,
869
+ new EmptyCallback() {
870
+ @Override
871
+ public void success() {
872
+ try {
873
+ File latestManifestFile = new File(temporaryDirectory, manifestFileName);
874
+ Manifest latestManifest = loadManifest(latestManifestFile);
875
+ // Load the current manifest
876
+ Manifest currentManifest = loadCurrentManifest();
877
+ // Compare the manifests
878
+ List<ManifestItem> itemsToCopy = new ArrayList<>();
879
+ List<ManifestItem> itemsToDownload = new ArrayList<>();
880
+ if (currentManifest == null) {
881
+ itemsToDownload.addAll(latestManifest.getItems());
882
+ } else {
883
+ itemsToCopy.addAll(Manifest.findDuplicateItems(latestManifest, currentManifest));
884
+ itemsToDownload.addAll(Manifest.findMissingItems(latestManifest, currentManifest));
885
+ }
886
+ // Copy the files
887
+ List<ManifestItem> missingItems = copyCurrentBundleFilesAndReturnFailures(itemsToCopy, temporaryDirectory);
888
+ // If items could not be copied, add them to the list of items to download
889
+ if (!missingItems.isEmpty()) {
890
+ itemsToDownload.addAll(missingItems);
891
+ }
892
+
893
+ // Download the files
894
+ downloadBundleFiles(
895
+ downloadUrl,
896
+ itemsToDownload,
897
+ temporaryDirectory,
898
+ (downloadedBytes, totalBytes) -> {
899
+ DownloadBundleProgressEvent event = new DownloadBundleProgressEvent(
900
+ bundleId,
901
+ downloadedBytes,
902
+ totalBytes
903
+ );
904
+ notifyDownloadBundleProgressListeners(event);
905
+ },
906
+ new EmptyCallback() {
907
+ @Override
908
+ public void success() {
909
+ try {
910
+ // Add the bundle
911
+ addBundleOfTypeManifest(bundleId, temporaryDirectory);
912
+ completionCallback.success();
913
+ } catch (Exception e) {
914
+ completionCallback.error(e);
915
+ }
916
+ }
917
+
918
+ @Override
919
+ public void error(@NonNull Exception exception) {
920
+ completionCallback.error(exception);
921
+ }
922
+ }
923
+ );
924
+ } catch (Exception e) {
925
+ completionCallback.error(e);
926
+ }
927
+ }
928
+
929
+ @Override
930
+ public void error(@NonNull Exception exception) {
931
+ completionCallback.error(exception);
932
+ }
933
+ }
934
+ );
935
+ } catch (Exception e) {
936
+ completionCallback.error(e);
937
+ }
938
+ }
939
+
940
+ private void downloadBundleOfTypeZip(
941
+ @NonNull String bundleId,
942
+ @Nullable String checksum,
943
+ @Nullable String signature,
944
+ @NonNull String downloadUrl,
945
+ @NonNull EmptyCallback completionCallback
946
+ ) {
947
+ File file = buildTemporaryZipFile();
948
+ // Download the bundle
949
+ downloadAndVerifyFile(
950
+ downloadUrl,
951
+ file,
952
+ checksum,
953
+ signature,
954
+ (downloadedBytes, totalBytes) -> {
955
+ DownloadBundleProgressEvent event = new DownloadBundleProgressEvent(bundleId, downloadedBytes, totalBytes);
956
+ notifyDownloadBundleProgressListeners(event);
957
+ },
958
+ new EmptyCallback() {
959
+ @Override
960
+ public void success() {
961
+ try {
962
+ // Add the bundle
963
+ addBundleOfTypeZip(bundleId, file);
964
+ // Delete the temporary file
965
+ file.delete();
966
+ completionCallback.success();
967
+ } catch (Exception e) {
968
+ completionCallback.error(e);
969
+ }
970
+ }
971
+
972
+ @Override
973
+ public void error(@NonNull Exception exception) {
974
+ // Delete the temporary file on error
975
+ file.delete();
976
+ completionCallback.error(exception);
977
+ }
978
+ }
979
+ );
980
+ }
981
+
982
+ private void fetchLatestBundleInternal(
983
+ @NonNull FetchLatestBundleOptions options,
984
+ @NonNull NonEmptyCallback<GetLatestBundleResponse> callback
985
+ ) {
986
+ try {
987
+ String channel = options.getChannel() == null ? getChannel() : options.getChannel();
988
+ String url = new HttpUrl.Builder()
989
+ .scheme("https")
990
+ .host(config.getServerDomain())
991
+ .addPathSegment("v1")
992
+ .addPathSegment("apps")
993
+ .addPathSegment(getAppId())
994
+ .addPathSegment("bundles")
995
+ .addPathSegment("latest")
996
+ .addQueryParameter("appVersionCode", getVersionCodeAsString())
997
+ .addQueryParameter("appVersionName", getVersionName())
998
+ .addQueryParameter("bundleId", getCurrentBundleId())
999
+ .addQueryParameter("channelName", channel)
1000
+ .addQueryParameter("customId", preferences.getCustomId())
1001
+ .addQueryParameter("deviceId", getDeviceId())
1002
+ .addQueryParameter("osVersion", String.valueOf(Build.VERSION.SDK_INT))
1003
+ .addQueryParameter("platform", "0")
1004
+ .addQueryParameter("pluginVersion", LiveUpdatePlugin.VERSION)
1005
+ .addQueryParameter("runtime", "cordova")
1006
+ .build()
1007
+ .toString();
1008
+ Log.d(LiveUpdatePlugin.TAG, "Fetching latest bundle: " + url);
1009
+
1010
+ httpClient.enqueue(
1011
+ url,
1012
+ new NonEmptyCallback<Response>() {
1013
+ @Override
1014
+ public void success(@NonNull Response response) {
1015
+ try {
1016
+ String responseBodyString = response.body().string();
1017
+ Log.d(LiveUpdatePlugin.TAG, "Latest bundle response: " + responseBodyString);
1018
+ if (response.isSuccessful()) {
1019
+ JSONObject responseJson = new JSONObject(responseBodyString);
1020
+ GetLatestBundleResponse result = new GetLatestBundleResponse(responseJson);
1021
+ callback.success(result);
1022
+ } else {
1023
+ callback.success(null);
1024
+ }
1025
+ } catch (Exception e) {
1026
+ callback.error(e);
1027
+ }
1028
+ }
1029
+
1030
+ @Override
1031
+ public void error(@NonNull Exception exception) {
1032
+ callback.error(exception);
1033
+ }
1034
+ }
1035
+ );
1036
+ } catch (Exception e) {
1037
+ callback.error(e);
1038
+ }
1039
+ }
1040
+
1041
+ private String[] getDownloadedBundleIds() {
1042
+ File bundlesDirectory = buildBundlesDirectory();
1043
+ File[] bundles = bundlesDirectory.listFiles();
1044
+ if (bundles == null) {
1045
+ return new String[0];
1046
+ }
1047
+
1048
+ String[] bundleIds = new String[bundles.length];
1049
+ for (int i = 0; i < bundles.length; i++) {
1050
+ bundleIds[i] = bundles[i].getName();
1051
+ }
1052
+
1053
+ return bundleIds;
1054
+ }
1055
+
1056
+ private byte[] getChecksumForFileAsBytes(@NonNull File file) throws Exception {
1057
+ try {
1058
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
1059
+ BufferedSource source = Okio.buffer(Okio.source(file));
1060
+ Buffer buffer = new Buffer();
1061
+ for (long bytesRead; (bytesRead = source.read(buffer, 2048)) != -1;) {
1062
+ digest.update(buffer.readByteArray());
1063
+ }
1064
+ source.close();
1065
+ return digest.digest();
1066
+ } catch (IOException exception) {
1067
+ Log.e(LiveUpdatePlugin.TAG, exception.getMessage(), exception);
1068
+ throw new Exception(LiveUpdatePlugin.ERROR_CHECKSUM_CALCULATION_FAILED);
1069
+ }
1070
+ }
1071
+
1072
+ private String getChecksumForFileAsString(@NonNull File file) throws Exception {
1073
+ byte[] checksumBytes = getChecksumForFileAsBytes(file);
1074
+ StringBuilder checksum = new StringBuilder();
1075
+ for (byte checksumByte : checksumBytes) {
1076
+ checksum.append(Integer.toString((checksumByte & 0xff) + 0x100, 16).substring(1));
1077
+ }
1078
+ return checksum.toString();
1079
+ }
1080
+
1081
+ @Nullable
1082
+ private String getAppId() {
1083
+ String appId = preferences.getAppId();
1084
+ if (appId != null) {
1085
+ return appId;
1086
+ }
1087
+ return config.getAppId();
1088
+ }
1089
+
1090
+ @Nullable
1091
+ private String getChannel() {
1092
+ String channel = null;
1093
+ if (config.getDefaultChannel() != null) {
1094
+ channel = config.getDefaultChannel();
1095
+ }
1096
+ String nativeChannel = getNativeChannel();
1097
+ if (nativeChannel != null) {
1098
+ channel = nativeChannel;
1099
+ }
1100
+ if (preferences.getChannel() != null) {
1101
+ channel = preferences.getChannel();
1102
+ }
1103
+ return channel;
1104
+ }
1105
+
1106
+ @Nullable
1107
+ private String getNativeChannel() {
1108
+ // Reads `capawesome_live_update_default_channel` from the merged string
1109
+ // resource namespace. The plugin seeds this name in our own strings file
1110
+ // (see plugin.xml); app developers can override it with `resValue` for
1111
+ // Versioned Channels — `resValue` wins per AGP's merge order.
1112
+ return LiveUpdatePlugin.readStringRes(
1113
+ plugin.getContext().getResources(),
1114
+ plugin.getContext().getPackageName(),
1115
+ "capawesome_live_update_default_channel"
1116
+ );
1117
+ }
1118
+
1119
+ /**
1120
+ * @return The current bundle ID or {@code null} if the default bundle is in use.
1121
+ */
1122
+ @Nullable
1123
+ private String getCurrentBundleId() {
1124
+ File dir = pathHandler.getActiveBundleDir();
1125
+ if (dir == null) {
1126
+ return null;
1127
+ }
1128
+ return dir.getName();
1129
+ }
1130
+
1131
+ @NonNull
1132
+ private String getDeviceId() {
1133
+ String deviceId = preferences.getDeviceIdForApp(getAppId());
1134
+ if (deviceId == null) {
1135
+ deviceId = UUID.randomUUID().toString().toLowerCase();
1136
+ preferences.setDeviceIdForApp(getAppId(), deviceId);
1137
+ }
1138
+ return deviceId;
1139
+ }
1140
+
1141
+ /**
1142
+ * @return The next bundle ID or {@code null} if the default bundle will be used.
1143
+ */
1144
+ @Nullable
1145
+ private String getNextBundleId() {
1146
+ return preferences.getNextBundleId();
1147
+ }
1148
+
1149
+ /**
1150
+ * @return The previous bundle ID or {@code null} if the default bundle was used.
1151
+ */
1152
+ @Nullable
1153
+ private String getPreviousBundleId() {
1154
+ return preferences.getPreviousBundleId();
1155
+ }
1156
+
1157
+ private int getVersionCodeAsInt() throws PackageManager.NameNotFoundException {
1158
+ return getPackageInfo().versionCode;
1159
+ }
1160
+
1161
+ private String getVersionCodeAsString() throws PackageManager.NameNotFoundException {
1162
+ return String.valueOf(getVersionCodeAsInt());
1163
+ }
1164
+
1165
+ private String getVersionName() throws PackageManager.NameNotFoundException {
1166
+ return getPackageInfo().versionName;
1167
+ }
1168
+
1169
+ private boolean hasBundleById(@NonNull String bundleId) {
1170
+ File bundleDirectory = buildBundleDirectoryFor(bundleId);
1171
+ return bundleDirectory.exists();
1172
+ }
1173
+
1174
+ private boolean isBundleInUse(@NonNull String bundleId) {
1175
+ String currentBundleId = getCurrentBundleId();
1176
+ String nextBundleId = getNextBundleId();
1177
+ return bundleId.equals(currentBundleId) || bundleId.equals(nextBundleId);
1178
+ }
1179
+
1180
+ @Nullable
1181
+ private Manifest loadCurrentManifest() throws Exception {
1182
+ String currentBundleId = getCurrentBundleId();
1183
+ if (currentBundleId == null) {
1184
+ AssetManager assets = plugin.getContext().getAssets();
1185
+ boolean manifestFileExists = Arrays.asList(assets.list(defaultWebAssetDir)).contains(manifestFileName);
1186
+ if (manifestFileExists) {
1187
+ InputStream inputStream = assets.open(defaultWebAssetDir + "/" + manifestFileName);
1188
+ BufferedSource source = Okio.buffer(Okio.source(inputStream));
1189
+ return loadManifest(source);
1190
+ } else {
1191
+ return null;
1192
+ }
1193
+ } else {
1194
+ File currentBundleDirectory = buildBundleDirectoryFor(currentBundleId);
1195
+ File manifestFile = new File(currentBundleDirectory, manifestFileName);
1196
+ if (manifestFile.exists()) {
1197
+ return loadManifest(manifestFile);
1198
+ } else {
1199
+ return null;
1200
+ }
1201
+ }
1202
+ }
1203
+
1204
+ private Manifest loadManifest(@NonNull BufferedSource source) throws Exception {
1205
+ String jsonAsString = source.readUtf8();
1206
+ JSONArray jsonArray = new JSONArray(jsonAsString);
1207
+ return new Manifest(jsonArray);
1208
+ }
1209
+
1210
+ private Manifest loadManifest(@NonNull File file) throws Exception {
1211
+ BufferedSource source = Okio.buffer(Okio.source(file));
1212
+ return loadManifest(source);
1213
+ }
1214
+
1215
+ private void notifyDownloadBundleProgressListeners(@NonNull final DownloadBundleProgressEvent event) {
1216
+ plugin.notifyDownloadBundleProgressListeners(event);
1217
+ }
1218
+
1219
+ private void performAutoUpdate() {
1220
+ // Check if enough time has passed since the last check
1221
+ long now = System.currentTimeMillis();
1222
+ if (lastAutoUpdateCheckTimestamp > 0 && (now - lastAutoUpdateCheckTimestamp) < autoUpdateIntervalMs) {
1223
+ Log.d(LiveUpdatePlugin.TAG, "Auto-update skipped. Last check was less than 15 minutes ago.");
1224
+ return;
1225
+ }
1226
+
1227
+ // Skip if appId is not configured
1228
+ String appId = getAppId();
1229
+ if (appId == null || appId.isEmpty()) {
1230
+ Log.d(LiveUpdatePlugin.TAG, "Auto-update skipped. appId is not configured.");
1231
+ return;
1232
+ }
1233
+
1234
+ // Update the timestamp
1235
+ lastAutoUpdateCheckTimestamp = now;
1236
+
1237
+ // Run sync
1238
+ Log.d(LiveUpdatePlugin.TAG, "Auto-update started.");
1239
+ SyncOptions options = new SyncOptions((String) null);
1240
+ NonEmptyCallback<Result> callback = new NonEmptyCallback<>() {
1241
+ @Override
1242
+ public void success(@NonNull Result result) {
1243
+ Log.d(LiveUpdatePlugin.TAG, "Auto-update completed successfully.");
1244
+ }
1245
+
1246
+ @Override
1247
+ public void error(@NonNull Exception exception) {
1248
+ Log.e(LiveUpdatePlugin.TAG, "Auto-update failed: " + exception.getMessage(), exception);
1249
+ }
1250
+ };
1251
+ sync(options, callback);
1252
+ }
1253
+
1254
+ private void rollback() {
1255
+ // Set the rollback flag
1256
+ rollbackPerformed = true;
1257
+ // Set the new previous bundle ID
1258
+ String currentBundleId = getCurrentBundleId();
1259
+ setPreviousBundleId(currentBundleId);
1260
+ // Log the rollback result
1261
+ if (currentBundleId == null) {
1262
+ Log.d(LiveUpdatePlugin.TAG, "App is not ready. Default bundle is already in use.");
1263
+ } else {
1264
+ Log.d(LiveUpdatePlugin.TAG, "App is not ready. Rolling back to default bundle.");
1265
+ // Rollback to the default bundle
1266
+ setNextBundleById(null);
1267
+ setCurrentBundleById(null);
1268
+ }
1269
+ }
1270
+
1271
+ @Nullable
1272
+ private File searchIndexHtmlFile(@NonNull File directory) {
1273
+ File[] files = directory.listFiles();
1274
+ if (files == null) {
1275
+ return null;
1276
+ }
1277
+ String[] fileNames = new String[files.length];
1278
+ for (int i = 0; i < files.length; i++) {
1279
+ fileNames[i] = files[i].getName();
1280
+ }
1281
+ if (Arrays.asList(fileNames).contains("index.html")) {
1282
+ return new File(directory, "index.html");
1283
+ } else {
1284
+ for (File file : files) {
1285
+ if (file.isDirectory()) {
1286
+ File indexHtmlFile = searchIndexHtmlFile(file);
1287
+ if (indexHtmlFile != null) {
1288
+ return indexHtmlFile;
1289
+ }
1290
+ }
1291
+ }
1292
+ }
1293
+ return null;
1294
+ }
1295
+
1296
+ /**
1297
+ * @param bundleId The bundle ID to set as the current bundle. If {@code null}, the default bundle will be used.
1298
+ */
1299
+ private void setCurrentBundleById(@Nullable String bundleId) {
1300
+ if (bundleId == null) {
1301
+ pathHandler.setActiveBundleDir(null);
1302
+ } else {
1303
+ pathHandler.setActiveBundleDir(buildBundleDirectoryFor(bundleId));
1304
+ }
1305
+ plugin.reloadWebView();
1306
+ }
1307
+
1308
+ /**
1309
+ * @param bundleId The bundle ID to set as the next bundle. If {@code null}, the default bundle will be used.
1310
+ */
1311
+ private void setNextBundleById(@Nullable String bundleId) {
1312
+ preferences.setNextBundleId(bundleId);
1313
+
1314
+ // Notify listeners
1315
+ notifyNextBundleSetListeners(bundleId);
1316
+ }
1317
+
1318
+ private void notifyNextBundleSetListeners(@Nullable String bundleId) {
1319
+ NextBundleSetEvent event = new NextBundleSetEvent(bundleId);
1320
+ plugin.notifyNextBundleSetListeners(event);
1321
+ }
1322
+
1323
+ private void addBlockedBundleId(@NonNull String bundleId) {
1324
+ String blockedIds = preferences.getBlockedBundleIds();
1325
+ List<String> blockedList = new ArrayList<>();
1326
+
1327
+ // Parse existing blocked IDs
1328
+ if (blockedIds != null && !blockedIds.isEmpty()) {
1329
+ String[] ids = blockedIds.split(",");
1330
+ blockedList.addAll(Arrays.asList(ids));
1331
+ }
1332
+
1333
+ // Skip if already blocked
1334
+ if (blockedList.contains(bundleId)) {
1335
+ return;
1336
+ }
1337
+
1338
+ // Remove oldest if limit reached
1339
+ if (blockedList.size() >= 100) {
1340
+ blockedList.remove(0);
1341
+ }
1342
+
1343
+ // Add new bundle
1344
+ blockedList.add(bundleId);
1345
+
1346
+ // Save back to preferences
1347
+ String newBlockedIds = String.join(",", blockedList);
1348
+ preferences.setBlockedBundleIds(newBlockedIds);
1349
+
1350
+ Log.d(LiveUpdatePlugin.TAG, "Bundle blocked: " + bundleId);
1351
+ }
1352
+
1353
+ private boolean isBlockedBundleId(@NonNull String bundleId) {
1354
+ String blockedIds = preferences.getBlockedBundleIds();
1355
+ if (blockedIds == null || blockedIds.isEmpty()) {
1356
+ return false;
1357
+ }
1358
+
1359
+ String[] ids = blockedIds.split(",");
1360
+ return Arrays.asList(ids).contains(bundleId);
1361
+ }
1362
+
1363
+ private void checkAndResetConfigIfVersionChanged() throws PackageManager.NameNotFoundException {
1364
+ int currentVersionCode = getVersionCodeAsInt();
1365
+ int lastVersionCode = preferences.getLastVersionCode();
1366
+
1367
+ if (lastVersionCode == -1 || lastVersionCode != currentVersionCode) {
1368
+ Log.d(
1369
+ LiveUpdatePlugin.TAG,
1370
+ "App version changed (last: " + lastVersionCode + ", current: " + currentVersionCode + "), resetting config."
1371
+ );
1372
+ resetConfig();
1373
+ // Reset the next bundle to the built-in one. The previously persisted
1374
+ // bundle was built for the old native binary and is no longer
1375
+ // compatible. Capacitor gets this for free because its core resets the
1376
+ // server base path on a native update; Cordova has no such mechanism,
1377
+ // so we must do it ourselves.
1378
+ preferences.setNextBundleId(null);
1379
+ preferences.setLastVersionCode(currentVersionCode);
1380
+ }
1381
+ }
1382
+
1383
+ private void setPreviousBundleId(@Nullable String bundleId) {
1384
+ preferences.setPreviousBundleId(bundleId);
1385
+ }
1386
+
1387
+ private void startRollbackTimer() {
1388
+ if (config.getReadyTimeout() <= 0) {
1389
+ return;
1390
+ }
1391
+ stopRollbackTimer();
1392
+ rollbackHandler.postDelayed(() -> rollback(), config.getReadyTimeout());
1393
+ }
1394
+
1395
+ private void stopRollbackTimer() {
1396
+ rollbackHandler.removeCallbacksAndMessages(null);
1397
+ }
1398
+
1399
+ private boolean tryCopyCurrentBundleFile(@NonNull ManifestItem fileToCopy, @NonNull File destinationDirectory) {
1400
+ try {
1401
+ copyCurrentBundleFile(fileToCopy, destinationDirectory);
1402
+ return true;
1403
+ } catch (IOException exception) {
1404
+ return false;
1405
+ }
1406
+ }
1407
+
1408
+ private File unzipFile(@NonNull File zipFile) throws IOException {
1409
+ File destination = buildTemporaryDirectory();
1410
+ String destinationPath = destination.getPath();
1411
+ ZipFile zip = new ZipFile(zipFile);
1412
+ // Clear stored Unix permissions to prevent EACCES errors on newer Android versions
1413
+ // where restrictive directory permissions from the zip block file creation.
1414
+ for (FileHeader fileHeader : zip.getFileHeaders()) {
1415
+ fileHeader.setExternalFileAttributes(null);
1416
+ }
1417
+ zip.extractAll(destinationPath);
1418
+ return destination;
1419
+ }
1420
+
1421
+ private boolean verifyChecksumForFile(@NonNull File file, @NonNull String checksum) throws Exception {
1422
+ String receivedChecksum = getChecksumForFileAsString(file);
1423
+ return checksum.equals(receivedChecksum);
1424
+ }
1425
+
1426
+ private void verifyFile(@NonNull File file, @Nullable String checksum, @Nullable String signature) throws Exception {
1427
+ String publicKey = config.getPublicKey();
1428
+ if (publicKey != null) {
1429
+ // Verify the signature
1430
+ if (signature == null) {
1431
+ throw new Exception(LiveUpdatePlugin.ERROR_SIGNATURE_MISSING);
1432
+ }
1433
+ // Verify the signature
1434
+ boolean verified = verifySignatureForFile(file, signature, publicKey);
1435
+ if (!verified) {
1436
+ throw new Exception(LiveUpdatePlugin.ERROR_SIGNATURE_VERIFICATION_FAILED);
1437
+ }
1438
+ }
1439
+ // Verify the checksum
1440
+ else if (checksum != null) {
1441
+ // Calculate the checksum
1442
+ boolean verified = verifyChecksumForFile(file, checksum);
1443
+ if (!verified) {
1444
+ throw new Exception(LiveUpdatePlugin.ERROR_CHECKSUM_MISMATCH);
1445
+ }
1446
+ }
1447
+ }
1448
+
1449
+ private boolean verifySignatureForFile(@NonNull File file, @NonNull String signature, @NonNull String keyAsString) throws Exception {
1450
+ PublicKey key = createPublicKeyFromString(keyAsString);
1451
+ return verifySignatureForFile(file, signature, key);
1452
+ }
1453
+
1454
+ private boolean verifySignatureForFile(@NonNull File file, @NonNull String signature, @NonNull PublicKey key) throws Exception {
1455
+ try {
1456
+ byte[] signatureBytes = Base64.decode(signature, Base64.DEFAULT);
1457
+ Signature sig = Signature.getInstance("SHA256withRSA");
1458
+ sig.initVerify(key);
1459
+ BufferedSource source = Okio.buffer(Okio.source(file));
1460
+ Buffer buffer = new Buffer();
1461
+ for (long bytesRead; (bytesRead = source.read(buffer, 2048)) != -1;) {
1462
+ sig.update(buffer.readByteArray());
1463
+ }
1464
+ source.close();
1465
+ return sig.verify(signatureBytes);
1466
+ } catch (Exception exception) {
1467
+ Log.e(LiveUpdatePlugin.TAG, exception.getMessage(), exception);
1468
+ throw new Exception(LiveUpdatePlugin.ERROR_SIGNATURE_VERIFICATION_FAILED);
1469
+ }
1470
+ }
1471
+
1472
+ private PackageInfo getPackageInfo() throws PackageManager.NameNotFoundException {
1473
+ String packageName = this.plugin.getContext().getPackageName();
1474
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
1475
+ return this.plugin.getContext().getPackageManager().getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0));
1476
+ } else {
1477
+ return this.plugin.getContext().getPackageManager().getPackageInfo(packageName, 0);
1478
+ }
1479
+ }
1480
+ }