@capawesome/capacitor-live-update 6.8.0 → 6.9.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 (56) hide show
  1. package/CapawesomeCapacitorLiveUpdate.podspec +2 -2
  2. package/README.md +429 -20
  3. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +851 -261
  4. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdateConfig.java +35 -0
  5. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdateHttpClient.java +56 -11
  6. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java +273 -38
  7. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePreferences.java +52 -3
  8. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/api/GetChannelsResponseItem.java +28 -0
  9. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/api/GetLatestBundleResponse.java +11 -2
  10. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/events/DownloadBundleProgressEvent.java +32 -0
  11. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/events/NextBundleSetEvent.java +26 -0
  12. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/FetchChannelsOptions.java +38 -0
  13. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/SetConfigOptions.java +19 -0
  14. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/SyncOptions.java +4 -0
  15. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/ChannelResult.java +28 -0
  16. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/FetchChannelsResult.java +28 -0
  17. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/FetchLatestBundleResult.java +8 -0
  18. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetBlockedBundlesResult.java +27 -0
  19. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetConfigResult.java +40 -0
  20. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetDownloadedBundlesResult.java +27 -0
  21. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/IsSyncingResult.java +26 -0
  22. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/ReadyResult.java +32 -0
  23. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/interfaces/DownloadProgressCallback.java +5 -0
  24. package/dist/docs.json +853 -50
  25. package/dist/esm/definitions.d.ts +411 -1
  26. package/dist/esm/definitions.js.map +1 -1
  27. package/dist/esm/web.d.ts +10 -2
  28. package/dist/esm/web.js +24 -0
  29. package/dist/esm/web.js.map +1 -1
  30. package/dist/plugin.cjs.js +24 -0
  31. package/dist/plugin.cjs.js.map +1 -1
  32. package/dist/plugin.js +24 -0
  33. package/dist/plugin.js.map +1 -1
  34. package/ios/Plugin/Classes/Events/DownloadBundleProgressEvent.swift +25 -0
  35. package/ios/Plugin/Classes/Events/NextBundleSetEvent.swift +16 -0
  36. package/ios/Plugin/Classes/Options/FetchChannelsOptions.swift +26 -0
  37. package/ios/Plugin/Classes/Options/SetConfigOptions.swift +14 -0
  38. package/ios/Plugin/Classes/Options/SyncOptions.swift +4 -0
  39. package/ios/Plugin/Classes/Results/ChannelResult.swift +19 -0
  40. package/ios/Plugin/Classes/Results/FetchChannelsResult.swift +16 -0
  41. package/ios/Plugin/Classes/Results/{FetchLatestBundle.swift → FetchLatestBundleResult.swift} +6 -1
  42. package/ios/Plugin/Classes/Results/GetBlockedBundlesResult.swift +16 -0
  43. package/ios/Plugin/Classes/Results/GetConfigResult.swift +19 -0
  44. package/ios/Plugin/Classes/Results/GetDownloadedBundlesResult.swift +16 -0
  45. package/ios/Plugin/Classes/Results/IsSyncingResult.swift +16 -0
  46. package/ios/Plugin/Classes/Results/ReadyResult.swift +22 -0
  47. package/ios/Plugin/LiveUpdate.swift +423 -145
  48. package/ios/Plugin/LiveUpdateConfig.swift +3 -0
  49. package/ios/Plugin/LiveUpdateHttpClient.swift +21 -4
  50. package/ios/Plugin/LiveUpdatePlugin.swift +174 -14
  51. package/ios/Plugin/LiveUpdatePreferences.swift +54 -12
  52. package/ios/Plugin/Protocols/Api/GetChannelsResponseItem.swift +4 -0
  53. package/ios/Plugin/Protocols/Api/GetLatestBundleResponse.swift +1 -0
  54. package/package.json +5 -5
  55. package/ios/Plugin/LiveUpdatePlugin.h +0 -10
  56. package/ios/Plugin/LiveUpdatePlugin.m +0 -27
@@ -10,31 +10,45 @@ import android.os.Handler;
10
10
  import android.util.Base64;
11
11
  import androidx.annotation.NonNull;
12
12
  import androidx.annotation.Nullable;
13
+ import com.getcapacitor.Bridge;
13
14
  import com.getcapacitor.Logger;
14
15
  import com.getcapacitor.plugin.WebView;
15
16
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.Manifest;
16
17
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.ManifestItem;
18
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.api.GetChannelsResponseItem;
17
19
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.api.GetLatestBundleResponse;
20
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.events.DownloadBundleProgressEvent;
21
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.events.NextBundleSetEvent;
18
22
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.DeleteBundleOptions;
19
23
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.DownloadBundleOptions;
24
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.FetchChannelsOptions;
20
25
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.FetchLatestBundleOptions;
21
26
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.SetBundleOptions;
22
27
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.SetChannelOptions;
28
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.SetConfigOptions;
23
29
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.SetCustomIdOptions;
24
30
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.SetNextBundleOptions;
25
31
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.SyncOptions;
32
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.ChannelResult;
33
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.FetchChannelsResult;
26
34
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.FetchLatestBundleResult;
35
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetBlockedBundlesResult;
27
36
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetBundleResult;
28
37
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetBundlesResult;
29
38
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetChannelResult;
39
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetConfigResult;
30
40
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetCurrentBundleResult;
31
41
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetCustomIdResult;
32
42
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetDeviceIdResult;
43
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetDownloadedBundlesResult;
33
44
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetNextBundleResult;
34
45
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetVersionCodeResult;
35
46
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetVersionNameResult;
47
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.IsSyncingResult;
48
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.ReadyResult;
36
49
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.SyncResult;
37
50
  import io.capawesome.capacitorjs.plugins.liveupdate.enums.ArtifactType;
51
+ import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.DownloadProgressCallback;
38
52
  import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.EmptyCallback;
39
53
  import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.NonEmptyCallback;
40
54
  import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.Result;
@@ -52,8 +66,12 @@ import java.util.ArrayList;
52
66
  import java.util.Arrays;
53
67
  import java.util.List;
54
68
  import java.util.UUID;
69
+ import java.util.concurrent.CountDownLatch;
70
+ import java.util.concurrent.atomic.AtomicBoolean;
71
+ import java.util.concurrent.atomic.AtomicLong;
55
72
  import java.util.concurrent.atomic.AtomicReference;
56
73
  import net.lingala.zip4j.ZipFile;
74
+ import okhttp3.Call;
57
75
  import okhttp3.HttpUrl;
58
76
  import okhttp3.Response;
59
77
  import okhttp3.ResponseBody;
@@ -65,13 +83,12 @@ import org.json.JSONObject;
65
83
 
66
84
  public class LiveUpdate {
67
85
 
86
+ private final long autoUpdateIntervalMs = 15 * 60 * 1000; // 15 minutes
87
+
68
88
  @NonNull
69
89
  private final LiveUpdateConfig config;
70
90
 
71
- private final String defaultWebAssetDir;
72
-
73
- @NonNull
74
- private final String host;
91
+ private final String defaultWebAssetDir = Bridge.DEFAULT_WEB_ASSET_DIR;
75
92
 
76
93
  @NonNull
77
94
  private final LiveUpdateHttpClient httpClient;
@@ -89,44 +106,57 @@ public class LiveUpdate {
89
106
  private final Handler rollbackHandler = new Handler();
90
107
  private final String manifestFileName = "capawesome-live-update-manifest.json"; // DO NOT CHANGE!
91
108
 
109
+ private boolean initialPageLoaded = false;
110
+ private long lastAutoUpdateCheckTimestamp = 0;
111
+ private boolean rollbackPerformed = false;
112
+ private boolean syncInProgress = false;
113
+
92
114
  public LiveUpdate(@NonNull LiveUpdateConfig config, @NonNull LiveUpdatePlugin plugin) throws PackageManager.NameNotFoundException {
93
115
  this.config = config;
94
- this.defaultWebAssetDir = plugin.getBridge().DEFAULT_WEB_ASSET_DIR;
95
- if (config.getLocation() != null && config.getLocation().equals("eu")) {
96
- this.host = "api.cloud.capawesome.eu";
97
- } else {
98
- this.host = "api.cloud.capawesome.io";
99
- }
100
116
  this.httpClient = new LiveUpdateHttpClient(config);
101
117
  this.plugin = plugin;
102
118
  this.preferences = new LiveUpdatePreferences(plugin.getContext());
103
119
  this.webViewSettingsEditor = plugin.getContext().getSharedPreferences(WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE).edit();
104
120
 
121
+ // Set the device ID on the HTTP client
122
+ this.httpClient.setDeviceId(getDeviceId());
123
+
105
124
  if (config.getEnabled()) {
106
- if (wasUpdated() && config.getResetOnUpdate()) {
125
+ boolean wasUpdated = wasUpdated();
126
+ if (wasUpdated) {
127
+ // Reset the runtime configuration if the app was updated to a new version
128
+ resetConfig();
129
+ }
130
+ if (wasUpdated && config.getResetOnUpdate()) {
107
131
  Logger.debug(LiveUpdatePlugin.TAG, "App was updated. Resetting to default bundle.");
108
132
  reset();
109
133
  } else {
134
+ // Start the rollback timer to rollback to the default bundle
135
+ // if the app is not ready after a certain time
110
136
  startRollbackTimer();
111
137
  }
112
138
  saveCurrentVersionCode();
113
139
  }
114
140
  }
115
141
 
142
+ public void clearBlockedBundles() {
143
+ preferences.setBlockedBundleIds(null);
144
+ }
145
+
116
146
  public void deleteBundle(@NonNull DeleteBundleOptions options, @NonNull EmptyCallback callback) {
117
147
  String bundleId = options.getBundleId();
118
148
 
119
- if (!hasBundle(bundleId)) {
149
+ if (!hasBundleById(bundleId)) {
120
150
  Exception exception = new Exception(LiveUpdatePlugin.ERROR_BUNDLE_NOT_FOUND);
121
151
  callback.error(exception);
122
152
  return;
123
153
  }
124
- deleteBundle(bundleId);
154
+ deleteBundleById(bundleId);
125
155
 
126
156
  callback.success();
127
157
  }
128
158
 
129
- public void downloadBundle(@NonNull DownloadBundleOptions options, @NonNull EmptyCallback callback) throws Exception {
159
+ public void downloadBundle(@NonNull DownloadBundleOptions options, @NonNull EmptyCallback callback) {
130
160
  ArtifactType artifactType = options.getArtifactType();
131
161
  String bundleId = options.getBundleId();
132
162
  String checksum = options.getChecksum();
@@ -134,7 +164,7 @@ public class LiveUpdate {
134
164
  String url = options.getUrl();
135
165
 
136
166
  // Check if the bundle already exists
137
- if (hasBundle(bundleId)) {
167
+ if (hasBundleById(bundleId)) {
138
168
  Exception exception = new Exception(LiveUpdatePlugin.ERROR_BUNDLE_EXISTS);
139
169
  callback.error(exception);
140
170
  return;
@@ -142,50 +172,143 @@ public class LiveUpdate {
142
172
 
143
173
  // Download the bundle
144
174
  if (artifactType == ArtifactType.MANIFEST) {
145
- downloadBundleOfTypeManifest(bundleId, url);
175
+ downloadBundleOfTypeManifest(bundleId, url, callback);
146
176
  } else {
147
- downloadBundleOfTypeZip(bundleId, url, checksum, signature);
177
+ downloadBundleOfTypeZip(bundleId, checksum, signature, url, callback);
148
178
  }
149
- callback.success();
150
179
  }
151
180
 
152
- public void fetchLatestBundle(@NonNull FetchLatestBundleOptions options, @NonNull NonEmptyCallback callback) throws Exception {
153
- GetLatestBundleResponse response = fetchLatestBundle(options);
154
- ArtifactType artifactType = response == null ? null : response.getArtifactType();
155
- String bundleId = response == null ? null : response.getBundleId();
156
- String checksum = response == null ? null : response.getChecksum();
157
- String downloadUrl = response == null ? null : response.getUrl();
158
- String signature = response == null ? null : response.getSignature();
159
- FetchLatestBundleResult result = new FetchLatestBundleResult(artifactType, bundleId, checksum, downloadUrl, signature);
181
+ public void fetchChannels(@NonNull FetchChannelsOptions options, @NonNull NonEmptyCallback<FetchChannelsResult> callback) {
182
+ try {
183
+ HttpUrl.Builder urlBuilder = new HttpUrl.Builder()
184
+ .scheme("https")
185
+ .host(config.getServerDomain())
186
+ .addPathSegment("v1")
187
+ .addPathSegment("apps")
188
+ .addPathSegment(getAppId())
189
+ .addPathSegment("channels");
190
+ if (options.getLimit() != null) {
191
+ urlBuilder.addQueryParameter("limit", String.valueOf(options.getLimit()));
192
+ }
193
+ if (options.getOffset() != null) {
194
+ urlBuilder.addQueryParameter("offset", String.valueOf(options.getOffset()));
195
+ }
196
+ if (options.getQuery() != null) {
197
+ urlBuilder.addQueryParameter("query", options.getQuery());
198
+ }
199
+ String url = urlBuilder.build().toString();
200
+
201
+ httpClient.enqueue(
202
+ url,
203
+ new NonEmptyCallback<Response>() {
204
+ @Override
205
+ public void success(@NonNull Response response) {
206
+ try {
207
+ String responseBodyString = response.body().string();
208
+ if (response.isSuccessful()) {
209
+ JSONArray responseJsonArray = new JSONArray(responseBodyString);
210
+ ChannelResult[] channels = new ChannelResult[responseJsonArray.length()];
211
+ for (int i = 0; i < responseJsonArray.length(); i++) {
212
+ GetChannelsResponseItem item = new GetChannelsResponseItem(responseJsonArray.getJSONObject(i));
213
+ channels[i] = new ChannelResult(item.getId(), item.getName());
214
+ }
215
+ FetchChannelsResult result = new FetchChannelsResult(channels);
216
+ callback.success(result);
217
+ } else {
218
+ callback.error(new Exception(responseBodyString));
219
+ }
220
+ } catch (Exception e) {
221
+ callback.error(e);
222
+ }
223
+ }
224
+
225
+ @Override
226
+ public void error(@NonNull Exception exception) {
227
+ callback.error(exception);
228
+ }
229
+ }
230
+ );
231
+ } catch (Exception e) {
232
+ callback.error(e);
233
+ }
234
+ }
235
+
236
+ public void fetchLatestBundle(@NonNull FetchLatestBundleOptions options, @NonNull NonEmptyCallback<FetchLatestBundleResult> callback) {
237
+ fetchLatestBundleInternal(
238
+ options,
239
+ new NonEmptyCallback<GetLatestBundleResponse>() {
240
+ @Override
241
+ public void success(@Nullable GetLatestBundleResponse response) {
242
+ ArtifactType artifactType = response == null ? null : response.getArtifactType();
243
+ String bundleId = response == null ? null : response.getBundleId();
244
+ String checksum = response == null ? null : response.getChecksum();
245
+ JSONObject customProperties = response == null ? null : response.getCustomProperties();
246
+ String downloadUrl = response == null ? null : response.getUrl();
247
+ String signature = response == null ? null : response.getSignature();
248
+ FetchLatestBundleResult result = new FetchLatestBundleResult(
249
+ artifactType,
250
+ bundleId,
251
+ checksum,
252
+ customProperties,
253
+ downloadUrl,
254
+ signature
255
+ );
256
+ callback.success(result);
257
+ }
258
+
259
+ @Override
260
+ public void error(@NonNull Exception exception) {
261
+ callback.error(exception);
262
+ }
263
+ }
264
+ );
265
+ }
266
+
267
+ public void getBlockedBundles(@NonNull NonEmptyCallback<GetBlockedBundlesResult> callback) {
268
+ String blockedIds = preferences.getBlockedBundleIds();
269
+ String[] bundleIds;
270
+ if (blockedIds == null || blockedIds.isEmpty()) {
271
+ bundleIds = new String[0];
272
+ } else {
273
+ bundleIds = blockedIds.split(",");
274
+ }
275
+ GetBlockedBundlesResult result = new GetBlockedBundlesResult(bundleIds);
160
276
  callback.success(result);
161
277
  }
162
278
 
163
- public void getBundle(@NonNull NonEmptyCallback callback) {
279
+ public void getBundle(@NonNull NonEmptyCallback<GetBundleResult> callback) {
164
280
  String bundleId = getCurrentBundleId();
165
- if (bundleId.equals(defaultWebAssetDir)) {
166
- bundleId = null;
167
- }
168
281
  GetBundleResult result = new GetBundleResult(bundleId);
169
282
  callback.success(result);
170
283
  }
171
284
 
172
285
  public void getBundles(@NonNull NonEmptyCallback callback) {
173
- String[] bundleIds = getBundleIds();
286
+ String[] bundleIds = getDownloadedBundleIds();
174
287
  GetBundlesResult result = new GetBundlesResult(bundleIds);
175
288
  callback.success(result);
176
289
  }
177
290
 
291
+ public void getDownloadedBundles(@NonNull NonEmptyCallback<GetDownloadedBundlesResult> callback) {
292
+ String[] bundleIds = getDownloadedBundleIds();
293
+ GetDownloadedBundlesResult result = new GetDownloadedBundlesResult(bundleIds);
294
+ callback.success(result);
295
+ }
296
+
178
297
  public void getChannel(@NonNull NonEmptyCallback callback) {
179
298
  String channel = getChannel();
180
299
  GetChannelResult result = new GetChannelResult(channel);
181
300
  callback.success(result);
182
301
  }
183
302
 
303
+ public void getConfig(@NonNull NonEmptyCallback<GetConfigResult> callback) {
304
+ String appId = getAppId();
305
+ String autoUpdateStrategy = config.getAutoUpdateStrategy();
306
+ GetConfigResult result = new GetConfigResult(appId, autoUpdateStrategy);
307
+ callback.success(result);
308
+ }
309
+
184
310
  public void getCurrentBundle(@NonNull NonEmptyCallback<GetCurrentBundleResult> callback) {
185
311
  String bundleId = getCurrentBundleId();
186
- if (bundleId.equals(defaultWebAssetDir)) {
187
- bundleId = null;
188
- }
189
312
  GetCurrentBundleResult result = new GetCurrentBundleResult(bundleId);
190
313
  callback.success(result);
191
314
  }
@@ -204,15 +327,12 @@ public class LiveUpdate {
204
327
 
205
328
  public void getNextBundle(@NonNull NonEmptyCallback<GetNextBundleResult> callback) {
206
329
  String bundleId = getNextBundleId();
207
- if (bundleId.equals(defaultWebAssetDir)) {
208
- bundleId = null;
209
- }
210
330
  GetNextBundleResult result = new GetNextBundleResult(bundleId);
211
331
  callback.success(result);
212
332
  }
213
333
 
214
334
  public void getVersionCode(@NonNull NonEmptyCallback callback) throws PackageManager.NameNotFoundException {
215
- String versionCode = getVersionCode();
335
+ String versionCode = getVersionCodeAsString();
216
336
  GetVersionCodeResult result = new GetVersionCodeResult(versionCode);
217
337
  callback.success(result);
218
338
  }
@@ -223,14 +343,51 @@ public class LiveUpdate {
223
343
  callback.success(result);
224
344
  }
225
345
 
226
- public void ready() {
346
+ public void isSyncing(@NonNull NonEmptyCallback<IsSyncingResult> callback) {
347
+ IsSyncingResult result = new IsSyncingResult(syncInProgress);
348
+ callback.success(result);
349
+ }
350
+
351
+ public void handleOnPageLoaded() {
352
+ // Wait for initial page load to perform auto update to make sure the WebViewLocalServer is initialized.
353
+ // Otherwise, a NullPointerException may occur for `com.getcapacitor.WebViewLocalServer.getBasePath()`.
354
+ if ("background".equals(config.getAutoUpdateStrategy()) && !initialPageLoaded) {
355
+ initialPageLoaded = true;
356
+ performAutoUpdate();
357
+ }
358
+ }
359
+
360
+ public void handleOnResume() {
361
+ if ("background".equals(config.getAutoUpdateStrategy()) && initialPageLoaded) {
362
+ performAutoUpdate();
363
+ }
364
+ }
365
+
366
+ public void ready(@NonNull NonEmptyCallback callback) {
227
367
  Logger.debug(LiveUpdatePlugin.TAG, "App is ready.");
368
+ if (config.getReadyTimeout() <= 0) {
369
+ Logger.warn(LiveUpdatePlugin.TAG, "Ready timeout is set to 0. Automatic rollback is disabled.");
370
+ }
228
371
  // Stop the rollback timer
229
372
  stopRollbackTimer();
230
373
  // Delete unused bundles
231
374
  if (config.getAutoDeleteBundles()) {
232
375
  deleteUnusedBundles();
233
376
  }
377
+ // Get the current and previous bundle IDs
378
+ String currentBundleId = getCurrentBundleId();
379
+ String previousBundleId = getPreviousBundleId();
380
+ // Block the rolled back bundle if enabled
381
+ if (config.getAutoBlockRolledBackBundles() && rollbackPerformed && previousBundleId != null) {
382
+ addBlockedBundleId(previousBundleId);
383
+ }
384
+ // Return the result
385
+ ReadyResult result = new ReadyResult(currentBundleId, previousBundleId, rollbackPerformed);
386
+ callback.success(result);
387
+ // Set the new previous bundle ID
388
+ setPreviousBundleId(currentBundleId);
389
+ // Reset the rollback flag
390
+ rollbackPerformed = false;
234
391
  }
235
392
 
236
393
  public void reload() {
@@ -240,19 +397,23 @@ public class LiveUpdate {
240
397
  }
241
398
 
242
399
  public void reset() {
243
- setNextCapacitorServerPathToDefaultWebAssetDir();
400
+ setNextBundleById(null);
401
+ }
402
+
403
+ public void resetConfig() {
404
+ preferences.setAppId(null);
244
405
  }
245
406
 
246
407
  public void setBundle(@NonNull SetBundleOptions options, @NonNull EmptyCallback callback) {
247
408
  String bundleId = options.getBundleId();
248
409
 
249
- if (!hasBundle(bundleId)) {
410
+ if (!hasBundleById(bundleId)) {
250
411
  Exception exception = new Exception(LiveUpdatePlugin.ERROR_BUNDLE_NOT_FOUND);
251
412
  callback.error(exception);
252
413
  return;
253
414
  }
254
415
 
255
- setNextBundle(bundleId);
416
+ setNextBundleById(bundleId);
256
417
  callback.success();
257
418
  }
258
419
 
@@ -263,6 +424,11 @@ public class LiveUpdate {
263
424
  callback.success();
264
425
  }
265
426
 
427
+ public void setConfig(@NonNull SetConfigOptions options) {
428
+ String appId = options.getAppId();
429
+ preferences.setAppId(appId);
430
+ }
431
+
266
432
  public void setCustomId(@NonNull SetCustomIdOptions options, @NonNull EmptyCallback callback) {
267
433
  String customId = options.getCustomId();
268
434
 
@@ -276,8 +442,8 @@ public class LiveUpdate {
276
442
  if (bundleId == null) {
277
443
  reset();
278
444
  } else {
279
- if (hasBundle(bundleId)) {
280
- setNextBundle(bundleId);
445
+ if (hasBundleById(bundleId)) {
446
+ setNextBundleById(bundleId);
281
447
  } else {
282
448
  Exception exception = new Exception(LiveUpdatePlugin.ERROR_BUNDLE_NOT_FOUND);
283
449
  callback.error(exception);
@@ -287,45 +453,99 @@ public class LiveUpdate {
287
453
  callback.success();
288
454
  }
289
455
 
290
- public void sync(@NonNull SyncOptions options, @NonNull NonEmptyCallback<Result> callback) throws Exception {
456
+ public void sync(@NonNull SyncOptions options, @NonNull NonEmptyCallback<Result> callback) {
457
+ if (syncInProgress) {
458
+ Exception exception = new Exception(LiveUpdatePlugin.ERROR_SYNC_IN_PROGRESS);
459
+ callback.error(exception);
460
+ return;
461
+ }
462
+ syncInProgress = true;
463
+
291
464
  String channel = options.getChannel();
292
465
  // Fetch the latest bundle
293
466
  FetchLatestBundleOptions fetchLatestBundleOptions = new FetchLatestBundleOptions(channel);
294
- GetLatestBundleResponse response = fetchLatestBundle(fetchLatestBundleOptions);
295
- if (response == null) {
296
- Logger.debug(LiveUpdatePlugin.TAG, "No update available.");
297
- SyncResult syncResult = new SyncResult(null);
298
- callback.success(syncResult);
299
- return;
300
- }
301
- ArtifactType artifactType = response.getArtifactType();
302
- String checksum = response.getChecksum();
303
- String signature = response.getSignature();
304
- String latestBundleId = response.getBundleId();
305
- String url = response.getUrl();
306
- // Check if the bundle already exists
307
- if (hasBundle(latestBundleId)) {
308
- String nextBundleId = null;
309
- String currentBundleId = getCurrentBundleId();
310
- if (!latestBundleId.equals(currentBundleId)) {
311
- // Set the next bundle
312
- setNextBundle(latestBundleId);
313
- nextBundleId = latestBundleId;
467
+ fetchLatestBundleInternal(
468
+ fetchLatestBundleOptions,
469
+ new NonEmptyCallback<GetLatestBundleResponse>() {
470
+ @Override
471
+ public void success(@Nullable GetLatestBundleResponse response) {
472
+ try {
473
+ if (response == null) {
474
+ Logger.debug(LiveUpdatePlugin.TAG, "No update available.");
475
+ syncInProgress = false;
476
+ SyncResult syncResult = new SyncResult(null);
477
+ callback.success(syncResult);
478
+ return;
479
+ }
480
+ ArtifactType artifactType = response.getArtifactType();
481
+ String latestBundleId = response.getBundleId();
482
+ String checksum = response.getChecksum();
483
+ String signature = response.getSignature();
484
+ String url = response.getUrl();
485
+ // Check if the bundle is blocked
486
+ if (isBlockedBundleId(latestBundleId)) {
487
+ Logger.warn(LiveUpdatePlugin.TAG, "Bundle is blocked and will not be downloaded.");
488
+ syncInProgress = false;
489
+ SyncResult syncResult = new SyncResult(null);
490
+ callback.success(syncResult);
491
+ return;
492
+ }
493
+ // Check if the bundle already exists
494
+ if (hasBundleById(latestBundleId)) {
495
+ String nextBundleId = null;
496
+ String currentBundleId = getCurrentBundleId();
497
+ if (!latestBundleId.equals(currentBundleId)) {
498
+ // Set the next bundle
499
+ setNextBundleById(latestBundleId);
500
+ nextBundleId = latestBundleId;
501
+ }
502
+ syncInProgress = false;
503
+ SyncResult syncResult = new SyncResult(nextBundleId);
504
+ callback.success(syncResult);
505
+ return;
506
+ }
507
+
508
+ // Download the bundle
509
+ EmptyCallback downloadCallback = new EmptyCallback() {
510
+ @Override
511
+ public void success() {
512
+ try {
513
+ // Set the next bundle
514
+ setNextBundleById(latestBundleId);
515
+ syncInProgress = false;
516
+ SyncResult syncResult = new SyncResult(latestBundleId);
517
+ callback.success(syncResult);
518
+ } catch (Exception e) {
519
+ syncInProgress = false;
520
+ callback.error(e);
521
+ }
522
+ }
523
+
524
+ @Override
525
+ public void error(@NonNull Exception exception) {
526
+ syncInProgress = false;
527
+ callback.error(exception);
528
+ }
529
+ };
530
+
531
+ if (artifactType == ArtifactType.MANIFEST) {
532
+ downloadBundleOfTypeManifest(latestBundleId, url, downloadCallback);
533
+ } else {
534
+ downloadBundleOfTypeZip(latestBundleId, checksum, signature, url, downloadCallback);
535
+ }
536
+ } catch (Exception e) {
537
+ syncInProgress = false;
538
+ callback.error(e);
539
+ }
540
+ }
541
+
542
+ @Override
543
+ public void error(@NonNull Exception exception) {
544
+ syncInProgress = false;
545
+ callback.error(exception);
546
+ }
314
547
  }
315
- SyncResult syncResult = new SyncResult(nextBundleId);
316
- callback.success(syncResult);
317
- return;
318
- }
319
- // Download the bundle
320
- if (artifactType == ArtifactType.MANIFEST) {
321
- downloadBundleOfTypeManifest(latestBundleId, url);
322
- } else {
323
- downloadBundleOfTypeZip(latestBundleId, url, checksum, signature);
324
- }
325
- // Set the next bundle
326
- setNextBundle(latestBundleId);
327
- SyncResult syncResult = new SyncResult(latestBundleId);
328
- callback.success(syncResult);
548
+ );
329
549
  }
330
550
 
331
551
  private void addBundle(@NonNull String bundleId, @NonNull File sourceDirectory) throws Exception {
@@ -372,9 +592,10 @@ public class LiveUpdate {
372
592
  return new File(plugin.getContext().getCacheDir(), fileName);
373
593
  }
374
594
 
375
- private void copyCurrentBundleFile(@NonNull String href, @NonNull File destinationDirectory) throws IOException {
595
+ private void copyCurrentBundleFile(@NonNull ManifestItem fileToCopy, @NonNull File destinationDirectory) throws IOException {
596
+ String href = fileToCopy.getHref();
376
597
  String currentBundleId = getCurrentBundleId();
377
- if (currentBundleId.equals(defaultWebAssetDir)) {
598
+ if (currentBundleId == null) {
378
599
  // Create the source input stream
379
600
  AssetManager assets = plugin.getContext().getAssets();
380
601
  InputStream inputStream = assets.open(defaultWebAssetDir + "/" + href);
@@ -396,10 +617,20 @@ public class LiveUpdate {
396
617
  }
397
618
  }
398
619
 
399
- private void copyCurrentBundleFiles(@NonNull List<String> hrefs, @NonNull File destinationDirectory) throws IOException {
400
- for (String href : hrefs) {
401
- copyCurrentBundleFile(href, destinationDirectory);
620
+ private List<ManifestItem> copyCurrentBundleFilesAndReturnFailures(
621
+ @NonNull List<ManifestItem> filesToCopy,
622
+ @NonNull File destinationDirectory
623
+ ) {
624
+ List<ManifestItem> missingItems = new ArrayList<>();
625
+ for (ManifestItem fileToCopy : filesToCopy) {
626
+ boolean success = tryCopyCurrentBundleFile(fileToCopy, destinationDirectory);
627
+ if (!success) {
628
+ Logger.warn(LiveUpdatePlugin.TAG, "Failed to copy file: " + fileToCopy.getHref());
629
+ // If the file could not be copied, add it to the list of missing items
630
+ missingItems.add(fileToCopy);
631
+ }
402
632
  }
633
+ return missingItems;
403
634
  }
404
635
 
405
636
  private void copyFile(File input, File output) throws IOException {
@@ -451,191 +682,381 @@ public class LiveUpdate {
451
682
  return file;
452
683
  }
453
684
 
454
- private void deleteBundle(@NonNull String bundleId) {
685
+ private void deleteBundleById(@NonNull String bundleId) {
455
686
  // Delete the bundle directory
456
687
  File bundleDirectory = buildBundleDirectoryFor(bundleId);
457
688
  deleteFileRecursively(bundleDirectory);
458
689
  // Reset the next bundle if it is the deleted bundle
459
690
  String nextBundleId = getNextBundleId();
460
691
  if (bundleId.equals(nextBundleId)) {
461
- setNextCapacitorServerPathToDefaultWebAssetDir();
692
+ setNextBundleById(null);
462
693
  }
463
694
  }
464
695
 
465
696
  private void deleteFileRecursively(@NonNull File file) {
466
697
  if (file.isDirectory()) {
467
- for (File child : file.listFiles()) {
468
- deleteFileRecursively(child);
698
+ File[] children = file.listFiles();
699
+ if (children != null) {
700
+ for (File child : children) {
701
+ deleteFileRecursively(child);
702
+ }
469
703
  }
470
704
  }
471
705
  file.delete();
472
706
  }
473
707
 
474
708
  private void deleteUnusedBundles() {
475
- String[] bundleIds = getBundleIds();
709
+ String[] bundleIds = getDownloadedBundleIds();
476
710
  for (String bundleId : bundleIds) {
477
711
  if (!isBundleInUse(bundleId)) {
478
- deleteBundle(bundleId);
712
+ deleteBundleById(bundleId);
479
713
  }
480
714
  }
481
715
  }
482
716
 
483
- private void downloadAndVerifyFile(@NonNull String url, @NonNull File file, @Nullable String checksum, @Nullable String signature)
484
- throws Exception {
485
- Response response = httpClient.execute(url);
486
- ResponseBody responseBody = response.body();
487
- if (response.isSuccessful()) {
488
- LiveUpdateHttpClient.writeResponseBodyToFile(responseBody, file);
489
- // Verify the file
490
- checksum = checksum == null ? LiveUpdateHttpClient.getChecksumFromResponse(response) : checksum;
491
- signature = signature == null ? LiveUpdateHttpClient.getSignatureFromResponse(response) : signature;
492
- verifyFile(file, checksum, signature);
493
- } else {
494
- Exception exception = new Exception(responseBody.string());
495
- Logger.error(LiveUpdatePlugin.TAG, exception.getMessage(), exception);
496
- throw new Exception(LiveUpdatePlugin.ERROR_DOWNLOAD_FAILED);
497
- }
717
+ private Call downloadAndVerifyFile(
718
+ @NonNull String url,
719
+ @NonNull File file,
720
+ @Nullable String checksum,
721
+ @Nullable String signature,
722
+ @Nullable DownloadProgressCallback progressCallback,
723
+ @NonNull EmptyCallback completionCallback
724
+ ) {
725
+ return httpClient.enqueue(
726
+ url,
727
+ new NonEmptyCallback<Response>() {
728
+ @Override
729
+ public void success(@NonNull Response response) {
730
+ try {
731
+ if (response.isSuccessful()) {
732
+ ResponseBody responseBody = response.body();
733
+ LiveUpdateHttpClient.writeResponseBodyToFile(responseBody, file, progressCallback);
734
+
735
+ // Extract checksum/signature from headers
736
+ String finalChecksum = checksum == null ? LiveUpdateHttpClient.getChecksumFromResponse(response) : checksum;
737
+ String finalSignature = signature == null ? LiveUpdateHttpClient.getSignatureFromResponse(response) : signature;
738
+
739
+ // Verify file
740
+ verifyFile(file, finalChecksum, finalSignature);
741
+ completionCallback.success();
742
+ } else {
743
+ String errorMessage = response.body().string();
744
+ Exception exception = new Exception(LiveUpdatePlugin.ERROR_DOWNLOAD_FAILED);
745
+ Logger.error(LiveUpdatePlugin.TAG, errorMessage, exception);
746
+ completionCallback.error(exception);
747
+ }
748
+ } catch (Exception e) {
749
+ completionCallback.error(e);
750
+ }
751
+ }
752
+
753
+ @Override
754
+ public void error(@NonNull Exception exception) {
755
+ completionCallback.error(exception);
756
+ }
757
+ }
758
+ );
498
759
  }
499
760
 
500
- private File downloadBundleFile(@NonNull String baseUrl, @NonNull String href, @NonNull File destinationDirectory) throws Exception {
761
+ private Call downloadBundleFile(
762
+ @NonNull String baseUrl,
763
+ @NonNull String href,
764
+ @NonNull File destinationDirectory,
765
+ @Nullable DownloadProgressCallback progressCallback,
766
+ @NonNull EmptyCallback completionCallback
767
+ ) {
501
768
  HttpUrl.Builder urlBuilder = HttpUrl.parse(baseUrl).newBuilder();
502
769
  urlBuilder.addQueryParameter("href", href);
503
770
  String url = urlBuilder.build().toString();
504
771
 
505
772
  File destinationFile = new File(destinationDirectory, href);
506
773
  destinationFile.getParentFile().mkdirs();
507
- downloadAndVerifyFile(url, destinationFile, null, null);
508
- return destinationFile;
509
- }
510
-
511
- private void downloadBundleFiles(@NonNull String baseUrl, @NonNull List<String> hrefs, @NonNull File destinationDirectory)
512
- throws Exception {
513
- // Limit the number of threads to twice the number of processors
514
- int maxThreads = Runtime.getRuntime().availableProcessors() * 2;
515
- List<Thread> threads = new ArrayList<>();
516
- AtomicReference<Exception> exceptionReference = new AtomicReference<>();
517
- for (String href : hrefs) {
518
- // Wait until a thread is available
519
- while (threads.size() >= maxThreads) {
520
- for (Thread thread : threads) {
521
- if (!thread.isAlive()) {
522
- threads.remove(thread);
523
- break;
774
+ return downloadAndVerifyFile(url, destinationFile, null, null, progressCallback, completionCallback);
775
+ }
776
+
777
+ private void downloadBundleFiles(
778
+ @NonNull String baseUrl,
779
+ @NonNull List<ManifestItem> filesToDownload,
780
+ @NonNull File destinationDirectory,
781
+ @Nullable DownloadProgressCallback progressCallback,
782
+ @NonNull EmptyCallback completionCallback
783
+ ) {
784
+ if (filesToDownload.isEmpty()) {
785
+ if (progressCallback != null) {
786
+ progressCallback.onProgress(0, 0);
787
+ }
788
+ completionCallback.success();
789
+ return;
790
+ }
791
+
792
+ // Thread-safe progress tracking
793
+ AtomicLong totalBytesDownloaded = new AtomicLong(0);
794
+ long totalBytesToDownload = 0;
795
+ for (ManifestItem item : filesToDownload) {
796
+ totalBytesToDownload += item.getSizeInBytes();
797
+ }
798
+ final long finalTotalBytesToDownload = totalBytesToDownload;
799
+
800
+ // Coordination primitives
801
+ CountDownLatch latch = new CountDownLatch(filesToDownload.size());
802
+ AtomicReference<Exception> firstError = new AtomicReference<>();
803
+ AtomicBoolean completionHandled = new AtomicBoolean(false);
804
+ List<Call> activeCalls = new ArrayList<>();
805
+
806
+ // Start all downloads asynchronously (OkHttp's dispatcher handles parallelization)
807
+ for (ManifestItem item : filesToDownload) {
808
+ Call call = downloadBundleFile(
809
+ baseUrl,
810
+ item.getHref(),
811
+ destinationDirectory,
812
+ (downloadedBytes, totalBytes) -> {
813
+ // Per-file progress
814
+ if (progressCallback != null) {
815
+ long totalProgress = totalBytesDownloaded.get() + downloadedBytes;
816
+ progressCallback.onProgress(totalProgress, finalTotalBytesToDownload);
817
+ }
818
+ },
819
+ new EmptyCallback() {
820
+ @Override
821
+ public void success() {
822
+ // Update total progress
823
+ totalBytesDownloaded.addAndGet(item.getSizeInBytes());
824
+ if (progressCallback != null) {
825
+ progressCallback.onProgress(totalBytesDownloaded.get(), finalTotalBytesToDownload);
826
+ }
827
+ latch.countDown();
828
+
829
+ // Check if this was the last download to complete
830
+ if (latch.getCount() == 0 && completionHandled.compareAndSet(false, true)) {
831
+ Exception error = firstError.get();
832
+ if (error != null) {
833
+ completionCallback.error(error);
834
+ } else {
835
+ // Final progress update
836
+ if (progressCallback != null) {
837
+ progressCallback.onProgress(finalTotalBytesToDownload, finalTotalBytesToDownload);
838
+ }
839
+ completionCallback.success();
840
+ }
841
+ }
842
+ }
843
+
844
+ @Override
845
+ public void error(@NonNull Exception e) {
846
+ // Capture first error and cancel all remaining downloads
847
+ if (firstError.compareAndSet(null, e)) {
848
+ Logger.error(LiveUpdatePlugin.TAG, "Failed to download file: " + item.getHref(), e);
849
+ // Cancel all in-flight downloads (fail-fast)
850
+ synchronized (activeCalls) {
851
+ for (Call activeCall : activeCalls) {
852
+ if (!activeCall.isCanceled() && !activeCall.isExecuted()) {
853
+ activeCall.cancel();
854
+ }
855
+ }
856
+ }
857
+ }
858
+ latch.countDown();
859
+
860
+ // Check if this was the last download to complete
861
+ if (latch.getCount() == 0 && completionHandled.compareAndSet(false, true)) {
862
+ completionCallback.error(firstError.get());
863
+ }
524
864
  }
525
865
  }
526
- // Wait a bit before checking again
527
- Thread.sleep(10);
866
+ );
867
+ synchronized (activeCalls) {
868
+ activeCalls.add(call);
528
869
  }
529
- // Stop if an exception occurred
530
- if (exceptionReference.get() != null) {
531
- break;
532
- }
533
- // Start a new thread
534
- Thread thread = new Thread(() -> {
535
- try {
536
- downloadBundleFile(baseUrl, href, destinationDirectory);
537
- } catch (Exception exception) {
538
- exceptionReference.set(exception);
539
- Logger.error(LiveUpdatePlugin.TAG, "Failed to download file: " + href, exception);
540
- }
541
- });
542
- threads.add(thread);
543
- thread.start();
544
- }
545
- // Wait for all threads to finish
546
- for (Thread thread : threads) {
547
- thread.join();
548
- }
549
- // Throw the exception if one occurred
550
- Exception exception = exceptionReference.get();
551
- if (exception != null) {
552
- throw exception;
553
- }
554
- }
555
-
556
- private void downloadBundleOfTypeManifest(@NonNull String bundleId, @NonNull String downloadUrl) throws Exception {
557
- // Create a temporary directory
558
- File temporaryDirectory = createTemporaryDirectory();
559
- // Download the latest manifest
560
- File latestManifestFile = downloadBundleFile(downloadUrl, manifestFileName, temporaryDirectory);
561
- Manifest latestManifest = loadManifest(latestManifestFile);
562
- // Load the current manifest
563
- Manifest currentManifest = loadCurrentManifest();
564
- // Compare the manifests
565
- List<ManifestItem> itemsToCopy = new ArrayList<>();
566
- List<ManifestItem> itemsToDownload = new ArrayList<>();
567
- if (currentManifest == null) {
568
- itemsToDownload.addAll(latestManifest.getItems());
569
- } else {
570
- itemsToCopy.addAll(Manifest.findDuplicateItems(latestManifest, currentManifest));
571
- itemsToDownload.addAll(Manifest.findMissingItems(latestManifest, currentManifest));
572
- }
573
- // Copy the files
574
- List<String> hrefsToCopy = new ArrayList<>();
575
- for (ManifestItem item : itemsToCopy) {
576
- hrefsToCopy.add(item.getHref());
577
870
  }
578
- copyCurrentBundleFiles(hrefsToCopy, temporaryDirectory);
579
- // Download the files
580
- List<String> hrefsToDownload = new ArrayList<>();
581
- for (ManifestItem item : itemsToDownload) {
582
- hrefsToDownload.add(item.getHref());
871
+ }
872
+
873
+ private void downloadBundleOfTypeManifest(
874
+ @NonNull String bundleId,
875
+ @NonNull String downloadUrl,
876
+ @NonNull EmptyCallback completionCallback
877
+ ) {
878
+ try {
879
+ // Create a temporary directory
880
+ File temporaryDirectory = createTemporaryDirectory();
881
+
882
+ // Download the latest manifest
883
+ downloadBundleFile(
884
+ downloadUrl,
885
+ manifestFileName,
886
+ temporaryDirectory,
887
+ null,
888
+ new EmptyCallback() {
889
+ @Override
890
+ public void success() {
891
+ try {
892
+ File latestManifestFile = new File(temporaryDirectory, manifestFileName);
893
+ Manifest latestManifest = loadManifest(latestManifestFile);
894
+ // Load the current manifest
895
+ Manifest currentManifest = loadCurrentManifest();
896
+ // Compare the manifests
897
+ List<ManifestItem> itemsToCopy = new ArrayList<>();
898
+ List<ManifestItem> itemsToDownload = new ArrayList<>();
899
+ if (currentManifest == null) {
900
+ itemsToDownload.addAll(latestManifest.getItems());
901
+ } else {
902
+ itemsToCopy.addAll(Manifest.findDuplicateItems(latestManifest, currentManifest));
903
+ itemsToDownload.addAll(Manifest.findMissingItems(latestManifest, currentManifest));
904
+ }
905
+ // Copy the files
906
+ List<ManifestItem> missingItems = copyCurrentBundleFilesAndReturnFailures(itemsToCopy, temporaryDirectory);
907
+ // If items could not be copied, add them to the list of items to download
908
+ if (!missingItems.isEmpty()) {
909
+ itemsToDownload.addAll(missingItems);
910
+ }
911
+
912
+ // Download the files
913
+ downloadBundleFiles(
914
+ downloadUrl,
915
+ itemsToDownload,
916
+ temporaryDirectory,
917
+ (downloadedBytes, totalBytes) -> {
918
+ DownloadBundleProgressEvent event = new DownloadBundleProgressEvent(
919
+ bundleId,
920
+ downloadedBytes,
921
+ totalBytes
922
+ );
923
+ notifyDownloadBundleProgressListeners(event);
924
+ },
925
+ new EmptyCallback() {
926
+ @Override
927
+ public void success() {
928
+ try {
929
+ // Add the bundle
930
+ addBundleOfTypeManifest(bundleId, temporaryDirectory);
931
+ completionCallback.success();
932
+ } catch (Exception e) {
933
+ completionCallback.error(e);
934
+ }
935
+ }
936
+
937
+ @Override
938
+ public void error(@NonNull Exception exception) {
939
+ completionCallback.error(exception);
940
+ }
941
+ }
942
+ );
943
+ } catch (Exception e) {
944
+ completionCallback.error(e);
945
+ }
946
+ }
947
+
948
+ @Override
949
+ public void error(@NonNull Exception exception) {
950
+ completionCallback.error(exception);
951
+ }
952
+ }
953
+ );
954
+ } catch (Exception e) {
955
+ completionCallback.error(e);
583
956
  }
584
- downloadBundleFiles(downloadUrl, hrefsToDownload, temporaryDirectory);
585
- // Add the bundle
586
- addBundleOfTypeManifest(bundleId, temporaryDirectory);
587
957
  }
588
958
 
589
959
  private void downloadBundleOfTypeZip(
590
960
  @NonNull String bundleId,
591
- @NonNull String downloadUrl,
592
961
  @Nullable String checksum,
593
- @Nullable String signature
594
- ) throws Exception {
962
+ @Nullable String signature,
963
+ @NonNull String downloadUrl,
964
+ @NonNull EmptyCallback completionCallback
965
+ ) {
595
966
  File file = buildTemporaryZipFile();
596
967
  // Download the bundle
597
- downloadAndVerifyFile(downloadUrl, file, checksum, signature);
598
- // Add the bundle
599
- addBundleOfTypeZip(bundleId, file);
600
- // Delete the temporary file
601
- file.delete();
968
+ downloadAndVerifyFile(
969
+ downloadUrl,
970
+ file,
971
+ checksum,
972
+ signature,
973
+ (downloadedBytes, totalBytes) -> {
974
+ DownloadBundleProgressEvent event = new DownloadBundleProgressEvent(bundleId, downloadedBytes, totalBytes);
975
+ notifyDownloadBundleProgressListeners(event);
976
+ },
977
+ new EmptyCallback() {
978
+ @Override
979
+ public void success() {
980
+ try {
981
+ // Add the bundle
982
+ addBundleOfTypeZip(bundleId, file);
983
+ // Delete the temporary file
984
+ file.delete();
985
+ completionCallback.success();
986
+ } catch (Exception e) {
987
+ completionCallback.error(e);
988
+ }
989
+ }
990
+
991
+ @Override
992
+ public void error(@NonNull Exception exception) {
993
+ // Delete the temporary file on error
994
+ file.delete();
995
+ completionCallback.error(exception);
996
+ }
997
+ }
998
+ );
602
999
  }
603
1000
 
604
- @Nullable
605
- private GetLatestBundleResponse fetchLatestBundle(@NonNull FetchLatestBundleOptions options) throws Exception {
606
- String channel = options.getChannel() == null ? getChannel() : options.getChannel();
607
- String url = new HttpUrl.Builder()
608
- .scheme("https")
609
- .host(host)
610
- .addPathSegment("v1")
611
- .addPathSegment("apps")
612
- .addPathSegment(config.getAppId())
613
- .addPathSegment("bundles")
614
- .addPathSegment("latest")
615
- .addQueryParameter("appVersionCode", getVersionCode())
616
- .addQueryParameter("appVersionName", getVersionName())
617
- .addQueryParameter("bundleId", getCurrentBundleId())
618
- .addQueryParameter("channelName", channel)
619
- .addQueryParameter("customId", preferences.getCustomId())
620
- .addQueryParameter("deviceId", getDeviceId())
621
- .addQueryParameter("osVersion", String.valueOf(Build.VERSION.SDK_INT))
622
- .addQueryParameter("platform", "0")
623
- .addQueryParameter("pluginVersion", LiveUpdatePlugin.VERSION)
624
- .build()
625
- .toString();
626
- Logger.debug(LiveUpdatePlugin.TAG, "Fetching latest bundle: " + url);
627
- Response response = httpClient.execute(url);
628
- String responseBodyString = response.body().string();
629
- Logger.debug(LiveUpdatePlugin.TAG, "Latest bundle response: " + responseBodyString);
630
- if (response.isSuccessful()) {
631
- JSONObject responseJson = new JSONObject(responseBodyString);
632
- return new GetLatestBundleResponse(responseJson);
633
- } else {
634
- return null;
1001
+ private void fetchLatestBundleInternal(
1002
+ @NonNull FetchLatestBundleOptions options,
1003
+ @NonNull NonEmptyCallback<GetLatestBundleResponse> callback
1004
+ ) {
1005
+ try {
1006
+ String channel = options.getChannel() == null ? getChannel() : options.getChannel();
1007
+ String url = new HttpUrl.Builder()
1008
+ .scheme("https")
1009
+ .host(config.getServerDomain())
1010
+ .addPathSegment("v1")
1011
+ .addPathSegment("apps")
1012
+ .addPathSegment(getAppId())
1013
+ .addPathSegment("bundles")
1014
+ .addPathSegment("latest")
1015
+ .addQueryParameter("appVersionCode", getVersionCodeAsString())
1016
+ .addQueryParameter("appVersionName", getVersionName())
1017
+ .addQueryParameter("bundleId", getCurrentBundleId())
1018
+ .addQueryParameter("channelName", channel)
1019
+ .addQueryParameter("customId", preferences.getCustomId())
1020
+ .addQueryParameter("deviceId", getDeviceId())
1021
+ .addQueryParameter("osVersion", String.valueOf(Build.VERSION.SDK_INT))
1022
+ .addQueryParameter("platform", "0")
1023
+ .addQueryParameter("pluginVersion", LiveUpdatePlugin.VERSION)
1024
+ .build()
1025
+ .toString();
1026
+ Logger.debug(LiveUpdatePlugin.TAG, "Fetching latest bundle: " + url);
1027
+
1028
+ httpClient.enqueue(
1029
+ url,
1030
+ new NonEmptyCallback<Response>() {
1031
+ @Override
1032
+ public void success(@NonNull Response response) {
1033
+ try {
1034
+ String responseBodyString = response.body().string();
1035
+ Logger.debug(LiveUpdatePlugin.TAG, "Latest bundle response: " + responseBodyString);
1036
+ if (response.isSuccessful()) {
1037
+ JSONObject responseJson = new JSONObject(responseBodyString);
1038
+ GetLatestBundleResponse result = new GetLatestBundleResponse(responseJson);
1039
+ callback.success(result);
1040
+ } else {
1041
+ callback.success(null);
1042
+ }
1043
+ } catch (Exception e) {
1044
+ callback.error(e);
1045
+ }
1046
+ }
1047
+
1048
+ @Override
1049
+ public void error(@NonNull Exception exception) {
1050
+ callback.error(exception);
1051
+ }
1052
+ }
1053
+ );
1054
+ } catch (Exception e) {
1055
+ callback.error(e);
635
1056
  }
636
1057
  }
637
1058
 
638
- private String[] getBundleIds() {
1059
+ private String[] getDownloadedBundleIds() {
639
1060
  File bundlesDirectory = buildBundlesDirectory();
640
1061
  File[] bundles = bundlesDirectory.listFiles();
641
1062
  if (bundles == null) {
@@ -675,9 +1096,22 @@ public class LiveUpdate {
675
1096
  return checksum.toString();
676
1097
  }
677
1098
 
1099
+ @Nullable
1100
+ private String getAppId() {
1101
+ String appId = preferences.getAppId();
1102
+ if (appId != null) {
1103
+ return appId;
1104
+ }
1105
+ return config.getAppId();
1106
+ }
1107
+
678
1108
  @Nullable
679
1109
  private String getChannel() {
680
1110
  String channel = null;
1111
+ String nativeChannel = getNativeChannel();
1112
+ if (nativeChannel != null) {
1113
+ channel = nativeChannel;
1114
+ }
681
1115
  if (config.getDefaultChannel() != null) {
682
1116
  channel = config.getDefaultChannel();
683
1117
  }
@@ -687,13 +1121,26 @@ public class LiveUpdate {
687
1121
  return channel;
688
1122
  }
689
1123
 
1124
+ @Nullable
1125
+ private String getNativeChannel() {
1126
+ int resId = plugin
1127
+ .getContext()
1128
+ .getResources()
1129
+ .getIdentifier("capawesome_live_update_default_channel", "string", plugin.getContext().getPackageName());
1130
+ if (resId == 0) {
1131
+ return null;
1132
+ }
1133
+ return plugin.getContext().getResources().getString(resId);
1134
+ }
1135
+
690
1136
  /**
691
- * @return The current bundle ID (`public` for the built-in bundle).
1137
+ * @return The current bundle ID or `null` if the default bundle is in use.
692
1138
  */
1139
+ @Nullable
693
1140
  private String getCurrentBundleId() {
694
1141
  String currentPath = getCurrentCapacitorServerPath();
695
1142
  if (currentPath.equals(defaultWebAssetDir)) {
696
- return defaultWebAssetDir;
1143
+ return null;
697
1144
  }
698
1145
  return new File(currentPath).getName();
699
1146
  }
@@ -707,22 +1154,22 @@ public class LiveUpdate {
707
1154
 
708
1155
  @NonNull
709
1156
  private String getDeviceId() {
710
- String deviceId = preferences.getDeviceIdForApp(config.getAppId());
1157
+ String deviceId = preferences.getDeviceIdForApp(getAppId());
711
1158
  if (deviceId == null) {
712
1159
  deviceId = UUID.randomUUID().toString().toLowerCase();
713
- preferences.setDeviceIdForApp(config.getAppId(), deviceId);
1160
+ preferences.setDeviceIdForApp(getAppId(), deviceId);
714
1161
  }
715
1162
  return deviceId;
716
1163
  }
717
1164
 
718
1165
  /**
719
- * @return The next bundle ID (`public` for the built-in bundle).
1166
+ * @return The next bundle ID or `null` if the default bundle will be used.
720
1167
  */
721
- @NonNull
1168
+ @Nullable
722
1169
  private String getNextBundleId() {
723
1170
  String nextPath = getNextCapacitorServerPath();
724
1171
  if (nextPath.equals(defaultWebAssetDir)) {
725
- return defaultWebAssetDir;
1172
+ return null;
726
1173
  }
727
1174
  return new File(nextPath).getName();
728
1175
  }
@@ -732,31 +1179,54 @@ public class LiveUpdate {
732
1179
  */
733
1180
  @NonNull
734
1181
  private String getNextCapacitorServerPath() {
735
- return plugin
1182
+ String path = plugin
736
1183
  .getContext()
737
1184
  .getSharedPreferences(WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE)
738
1185
  .getString(WebView.CAP_SERVER_PATH, defaultWebAssetDir);
1186
+ // Empty path means default path
1187
+ if (path.isEmpty()) {
1188
+ path = defaultWebAssetDir;
1189
+ }
1190
+ return path;
1191
+ }
1192
+
1193
+ /**
1194
+ * @return The previous bundle ID or `null` if the default bundle was used.
1195
+ */
1196
+ @Nullable
1197
+ private String getPreviousBundleId() {
1198
+ return preferences.getPreviousBundleId();
739
1199
  }
740
1200
 
741
- private String getVersionCode() throws PackageManager.NameNotFoundException {
742
- return String.valueOf(getPackageInfo().versionCode);
1201
+ private int getVersionCodeAsInt() throws PackageManager.NameNotFoundException {
1202
+ return getPackageInfo().versionCode;
1203
+ }
1204
+
1205
+ private String getVersionCodeAsString() throws PackageManager.NameNotFoundException {
1206
+ return String.valueOf(getVersionCodeAsInt());
743
1207
  }
744
1208
 
745
1209
  private String getVersionName() throws PackageManager.NameNotFoundException {
746
1210
  return getPackageInfo().versionName;
747
1211
  }
748
1212
 
749
- private boolean hasBundle(@NonNull String bundleId) {
1213
+ private boolean hasBundleById(@NonNull String bundleId) {
750
1214
  File bundleDirectory = buildBundleDirectoryFor(bundleId);
751
1215
  return bundleDirectory.exists();
752
1216
  }
753
1217
 
1218
+ private boolean isBundleInUse(@NonNull String bundleId) {
1219
+ String currentBundleId = getCurrentBundleId();
1220
+ String nextBundleId = getNextBundleId();
1221
+ return bundleId.equals(currentBundleId) || bundleId.equals(nextBundleId);
1222
+ }
1223
+
754
1224
  @Nullable
755
1225
  private Manifest loadCurrentManifest() throws Exception {
756
1226
  String currentBundleId = getCurrentBundleId();
757
- if (currentBundleId.equals(defaultWebAssetDir)) {
1227
+ if (currentBundleId == null) {
758
1228
  AssetManager assets = plugin.getContext().getAssets();
759
- Boolean manifestFileExists = Arrays.asList(assets.list(defaultWebAssetDir)).contains(manifestFileName);
1229
+ boolean manifestFileExists = Arrays.asList(assets.list(defaultWebAssetDir)).contains(manifestFileName);
760
1230
  if (manifestFileExists) {
761
1231
  InputStream inputStream = assets.open(defaultWebAssetDir + "/" + manifestFileName);
762
1232
  BufferedSource source = Okio.buffer(Okio.source(inputStream));
@@ -786,25 +1256,53 @@ public class LiveUpdate {
786
1256
  return loadManifest(source);
787
1257
  }
788
1258
 
789
- private boolean isBundleInUse(@NonNull String bundleId) {
790
- String currentBundleId = getCurrentBundleId();
791
- String nextBundleId = getNextBundleId();
792
- return bundleId.equals(currentBundleId) || bundleId.equals(nextBundleId);
1259
+ private void notifyDownloadBundleProgressListeners(@NonNull final DownloadBundleProgressEvent event) {
1260
+ plugin.notifyDownloadBundleProgressListeners(event);
793
1261
  }
794
1262
 
795
- private void rollback() {
796
- if (getCurrentBundleId() == defaultWebAssetDir) {
797
- Logger.debug(LiveUpdatePlugin.TAG, "App is not ready. Default bundle is already in use.");
1263
+ private void performAutoUpdate() {
1264
+ // Check if enough time has passed since the last check
1265
+ long now = System.currentTimeMillis();
1266
+ if (lastAutoUpdateCheckTimestamp > 0 && (now - lastAutoUpdateCheckTimestamp) < autoUpdateIntervalMs) {
1267
+ Logger.debug(LiveUpdatePlugin.TAG, "Auto-update skipped. Last check was less than 15 minutes ago.");
798
1268
  return;
799
1269
  }
800
- Logger.debug(LiveUpdatePlugin.TAG, "App is not ready. Rolling back to default bundle.");
801
- setNextCapacitorServerPathToDefaultWebAssetDir();
802
- setCurrentCapacitorServerPathToDefaultWebAssetDir();
1270
+
1271
+ // Update the timestamp
1272
+ lastAutoUpdateCheckTimestamp = now;
1273
+
1274
+ // Run sync
1275
+ Logger.debug(LiveUpdatePlugin.TAG, "Auto-update started.");
1276
+ SyncOptions options = new SyncOptions((String) null);
1277
+ NonEmptyCallback<Result> callback = new NonEmptyCallback<>() {
1278
+ @Override
1279
+ public void success(@NonNull Result result) {
1280
+ Logger.debug(LiveUpdatePlugin.TAG, "Auto-update completed successfully.");
1281
+ }
1282
+
1283
+ @Override
1284
+ public void error(@NonNull Exception exception) {
1285
+ Logger.error(LiveUpdatePlugin.TAG, "Auto-update failed: " + exception.getMessage(), exception);
1286
+ }
1287
+ };
1288
+ sync(options, callback);
803
1289
  }
804
1290
 
805
- private void saveCurrentVersionCode() throws PackageManager.NameNotFoundException {
806
- int currentVersionCode = getPackageInfo().versionCode;
807
- preferences.setLastVersionCode(currentVersionCode);
1291
+ private void rollback() {
1292
+ // Set the rollback flag
1293
+ rollbackPerformed = true;
1294
+ // Set the new previous bundle ID
1295
+ String currentBundleId = getCurrentBundleId();
1296
+ setPreviousBundleId(currentBundleId);
1297
+ // Log the rollback result
1298
+ if (currentBundleId == null) {
1299
+ Logger.debug(LiveUpdatePlugin.TAG, "App is not ready. Default bundle is already in use.");
1300
+ } else {
1301
+ Logger.debug(LiveUpdatePlugin.TAG, "App is not ready. Rolling back to default bundle.");
1302
+ // Rollback to the default bundle
1303
+ setNextBundleById(null);
1304
+ setCurrentBundleById(null);
1305
+ }
808
1306
  }
809
1307
 
810
1308
  @Nullable
@@ -832,6 +1330,18 @@ public class LiveUpdate {
832
1330
  return null;
833
1331
  }
834
1332
 
1333
+ /**
1334
+ * @param bundleId The bundle ID to set as the current bundle. If `null`, the default bundle will be used.
1335
+ */
1336
+ private void setCurrentBundleById(@Nullable String bundleId) {
1337
+ if (bundleId == null) {
1338
+ setCurrentCapacitorServerPath(defaultWebAssetDir);
1339
+ } else {
1340
+ File bundleDirectory = buildBundleDirectoryFor(bundleId);
1341
+ setCurrentCapacitorServerPath(bundleDirectory.getPath());
1342
+ }
1343
+ }
1344
+
835
1345
  private void setCurrentCapacitorServerPath(@NonNull String path) {
836
1346
  if (path.equals(defaultWebAssetDir)) {
837
1347
  this.plugin.getBridge().setServerAssetPath(path);
@@ -839,15 +1349,72 @@ public class LiveUpdate {
839
1349
  this.plugin.getBridge().setServerBasePath(path);
840
1350
  }
841
1351
  this.plugin.getBridge().reload();
1352
+ // Notify listeners
1353
+ notifyReloadedListeners();
1354
+ }
1355
+
1356
+ /**
1357
+ * @param bundleId The bundle ID to set as the next bundle. If `null`, the default bundle will be used.
1358
+ */
1359
+ private void setNextBundleById(@Nullable String bundleId) {
1360
+ if (bundleId == null) {
1361
+ setNextCapacitorServerPath(defaultWebAssetDir);
1362
+ } else {
1363
+ File bundleDirectory = buildBundleDirectoryFor(bundleId);
1364
+ setNextCapacitorServerPath(bundleDirectory.getPath());
1365
+ }
1366
+
1367
+ // Notify listeners
1368
+ notifyNextBundleSetListeners(bundleId);
842
1369
  }
843
1370
 
844
- private void setCurrentCapacitorServerPathToDefaultWebAssetDir() {
845
- setCurrentCapacitorServerPath(defaultWebAssetDir);
1371
+ private void notifyNextBundleSetListeners(@Nullable String bundleId) {
1372
+ NextBundleSetEvent event = new NextBundleSetEvent(bundleId);
1373
+ plugin.notifyNextBundleSetListeners(event);
846
1374
  }
847
1375
 
848
- private void setNextBundle(@NonNull String bundleId) {
849
- File bundleDirectory = buildBundleDirectoryFor(bundleId);
850
- setNextCapacitorServerPath(bundleDirectory.getPath());
1376
+ private void notifyReloadedListeners() {
1377
+ plugin.notifyReloadedListeners();
1378
+ }
1379
+
1380
+ private void addBlockedBundleId(@NonNull String bundleId) {
1381
+ String blockedIds = preferences.getBlockedBundleIds();
1382
+ List<String> blockedList = new ArrayList<>();
1383
+
1384
+ // Parse existing blocked IDs
1385
+ if (blockedIds != null && !blockedIds.isEmpty()) {
1386
+ String[] ids = blockedIds.split(",");
1387
+ blockedList.addAll(Arrays.asList(ids));
1388
+ }
1389
+
1390
+ // Skip if already blocked
1391
+ if (blockedList.contains(bundleId)) {
1392
+ return;
1393
+ }
1394
+
1395
+ // Remove oldest if limit reached
1396
+ if (blockedList.size() >= 100) {
1397
+ blockedList.remove(0);
1398
+ }
1399
+
1400
+ // Add new bundle
1401
+ blockedList.add(bundleId);
1402
+
1403
+ // Save back to preferences
1404
+ String newBlockedIds = String.join(",", blockedList);
1405
+ preferences.setBlockedBundleIds(newBlockedIds);
1406
+
1407
+ Logger.debug(LiveUpdatePlugin.TAG, "Bundle blocked: " + bundleId);
1408
+ }
1409
+
1410
+ private boolean isBlockedBundleId(@NonNull String bundleId) {
1411
+ String blockedIds = preferences.getBlockedBundleIds();
1412
+ if (blockedIds == null || blockedIds.isEmpty()) {
1413
+ return false;
1414
+ }
1415
+
1416
+ String[] ids = blockedIds.split(",");
1417
+ return Arrays.asList(ids).contains(bundleId);
851
1418
  }
852
1419
 
853
1420
  private void setNextCapacitorServerPath(@NonNull String path) {
@@ -855,11 +1422,25 @@ public class LiveUpdate {
855
1422
  this.webViewSettingsEditor.commit();
856
1423
  }
857
1424
 
858
- private void setNextCapacitorServerPathToDefaultWebAssetDir() {
859
- setNextCapacitorServerPath(defaultWebAssetDir);
1425
+ private boolean wasUpdated() throws PackageManager.NameNotFoundException {
1426
+ int lastVersionCode = preferences.getLastVersionCode();
1427
+ int currentVersionCode = getVersionCodeAsInt();
1428
+ return lastVersionCode != currentVersionCode;
1429
+ }
1430
+
1431
+ private void saveCurrentVersionCode() throws PackageManager.NameNotFoundException {
1432
+ int currentVersionCode = getVersionCodeAsInt();
1433
+ preferences.setLastVersionCode(currentVersionCode);
1434
+ }
1435
+
1436
+ private void setPreviousBundleId(@Nullable String bundleId) {
1437
+ preferences.setPreviousBundleId(bundleId);
860
1438
  }
861
1439
 
862
1440
  private void startRollbackTimer() {
1441
+ if (config.getReadyTimeout() <= 0) {
1442
+ return;
1443
+ }
863
1444
  stopRollbackTimer();
864
1445
  rollbackHandler.postDelayed(() -> rollback(), config.getReadyTimeout());
865
1446
  }
@@ -868,10 +1449,25 @@ public class LiveUpdate {
868
1449
  rollbackHandler.removeCallbacksAndMessages(null);
869
1450
  }
870
1451
 
1452
+ private boolean tryCopyCurrentBundleFile(@NonNull ManifestItem fileToCopy, @NonNull File destinationDirectory) {
1453
+ try {
1454
+ copyCurrentBundleFile(fileToCopy, destinationDirectory);
1455
+ return true;
1456
+ } catch (IOException exception) {
1457
+ return false;
1458
+ }
1459
+ }
1460
+
871
1461
  private File unzipFile(@NonNull File zipFile) throws IOException {
872
1462
  File destination = buildTemporaryDirectory();
873
1463
  String destinationPath = destination.getPath();
874
- new ZipFile(zipFile).extractAll(destinationPath);
1464
+ ZipFile zip = new ZipFile(zipFile);
1465
+ // Clear stored Unix permissions to prevent EACCES errors on newer Android versions
1466
+ // where restrictive directory permissions from the zip block file creation.
1467
+ for (net.lingala.zip4j.model.FileHeader fileHeader : zip.getFileHeaders()) {
1468
+ fileHeader.setExternalFileAttributes(null);
1469
+ }
1470
+ zip.extractAll(destinationPath);
875
1471
  return destination;
876
1472
  }
877
1473
 
@@ -926,12 +1522,6 @@ public class LiveUpdate {
926
1522
  }
927
1523
  }
928
1524
 
929
- private boolean wasUpdated() throws PackageManager.NameNotFoundException {
930
- int lastVersionCode = preferences.getLastVersionCode();
931
- int currentVersionCode = getPackageInfo().versionCode;
932
- return lastVersionCode != currentVersionCode;
933
- }
934
-
935
1525
  private PackageInfo getPackageInfo() throws PackageManager.NameNotFoundException {
936
1526
  String packageName = this.plugin.getContext().getPackageName();
937
1527
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {