@capawesome/capacitor-live-update 6.8.0 → 7.0.0-dev.2633ff2.1765953041

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 (54) hide show
  1. package/CapawesomeCapacitorLiveUpdate.podspec +3 -3
  2. package/Package.swift +32 -0
  3. package/README.md +438 -86
  4. package/android/build.gradle +12 -12
  5. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +754 -279
  6. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdateConfig.java +29 -27
  7. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdateHttpClient.java +40 -7
  8. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java +210 -67
  9. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePreferences.java +52 -3
  10. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/api/GetLatestBundleResponse.java +11 -2
  11. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/events/DownloadBundleProgressEvent.java +32 -0
  12. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/{results/GetBundleResult.java → events/NextBundleSetEvent.java} +9 -7
  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/FetchLatestBundleResult.java +8 -0
  16. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetBlockedBundlesResult.java +27 -0
  17. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetConfigResult.java +40 -0
  18. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetDownloadedBundlesResult.java +27 -0
  19. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/IsSyncingResult.java +26 -0
  20. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/ReadyResult.java +32 -0
  21. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/interfaces/DownloadProgressCallback.java +5 -0
  22. package/dist/docs.json +597 -102
  23. package/dist/esm/definitions.d.ts +338 -39
  24. package/dist/esm/definitions.js.map +1 -1
  25. package/dist/esm/index.js +1 -1
  26. package/dist/esm/index.js.map +1 -1
  27. package/dist/esm/web.d.ts +9 -2
  28. package/dist/esm/web.js +21 -0
  29. package/dist/esm/web.js.map +1 -1
  30. package/dist/plugin.cjs.js +22 -3
  31. package/dist/plugin.cjs.js.map +1 -1
  32. package/dist/plugin.js +22 -3
  33. package/dist/plugin.js.map +1 -1
  34. package/ios/Plugin/Classes/Events/DownloadBundleProgressEvent.swift +25 -0
  35. package/ios/Plugin/Classes/{Results/GetBundleResult.swift → Events/NextBundleSetEvent.swift} +3 -3
  36. package/ios/Plugin/Classes/Options/SetConfigOptions.swift +14 -0
  37. package/ios/Plugin/Classes/Options/SyncOptions.swift +4 -0
  38. package/ios/Plugin/Classes/Results/{FetchLatestBundle.swift → FetchLatestBundleResult.swift} +6 -1
  39. package/ios/Plugin/Classes/Results/GetBlockedBundlesResult.swift +16 -0
  40. package/ios/Plugin/Classes/Results/GetConfigResult.swift +19 -0
  41. package/ios/Plugin/Classes/Results/GetDownloadedBundlesResult.swift +16 -0
  42. package/ios/Plugin/Classes/Results/IsSyncingResult.swift +16 -0
  43. package/ios/Plugin/Classes/Results/ReadyResult.swift +22 -0
  44. package/ios/Plugin/LiveUpdate.swift +365 -149
  45. package/ios/Plugin/LiveUpdateConfig.swift +4 -4
  46. package/ios/Plugin/LiveUpdateHttpClient.swift +10 -4
  47. package/ios/Plugin/LiveUpdatePlugin.swift +136 -34
  48. package/ios/Plugin/LiveUpdatePreferences.swift +54 -12
  49. package/ios/Plugin/Protocols/Api/GetLatestBundleResponse.swift +1 -0
  50. package/package.json +19 -20
  51. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/SetBundleOptions.java +0 -18
  52. package/ios/Plugin/Classes/Options/SetBundleOptions.swift +0 -14
  53. package/ios/Plugin/LiveUpdatePlugin.h +0 -10
  54. package/ios/Plugin/LiveUpdatePlugin.m +0 -27
@@ -10,31 +10,39 @@ 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;
17
18
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.api.GetLatestBundleResponse;
19
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.events.DownloadBundleProgressEvent;
20
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.events.NextBundleSetEvent;
18
21
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.DeleteBundleOptions;
19
22
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.DownloadBundleOptions;
20
23
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.FetchLatestBundleOptions;
21
- import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.SetBundleOptions;
22
24
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.SetChannelOptions;
25
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.SetConfigOptions;
23
26
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.SetCustomIdOptions;
24
27
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.SetNextBundleOptions;
25
28
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.SyncOptions;
26
29
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.FetchLatestBundleResult;
27
- import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetBundleResult;
30
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetBlockedBundlesResult;
28
31
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetBundlesResult;
29
32
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetChannelResult;
33
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetConfigResult;
30
34
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetCurrentBundleResult;
31
35
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetCustomIdResult;
32
36
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetDeviceIdResult;
37
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetDownloadedBundlesResult;
33
38
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetNextBundleResult;
34
39
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetVersionCodeResult;
35
40
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.GetVersionNameResult;
41
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.IsSyncingResult;
42
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.ReadyResult;
36
43
  import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.SyncResult;
37
44
  import io.capawesome.capacitorjs.plugins.liveupdate.enums.ArtifactType;
45
+ import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.DownloadProgressCallback;
38
46
  import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.EmptyCallback;
39
47
  import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.NonEmptyCallback;
40
48
  import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.Result;
@@ -52,8 +60,12 @@ import java.util.ArrayList;
52
60
  import java.util.Arrays;
53
61
  import java.util.List;
54
62
  import java.util.UUID;
63
+ import java.util.concurrent.CountDownLatch;
64
+ import java.util.concurrent.atomic.AtomicBoolean;
65
+ import java.util.concurrent.atomic.AtomicLong;
55
66
  import java.util.concurrent.atomic.AtomicReference;
56
67
  import net.lingala.zip4j.ZipFile;
68
+ import okhttp3.Call;
57
69
  import okhttp3.HttpUrl;
58
70
  import okhttp3.Response;
59
71
  import okhttp3.ResponseBody;
@@ -65,13 +77,12 @@ import org.json.JSONObject;
65
77
 
66
78
  public class LiveUpdate {
67
79
 
80
+ private final long autoUpdateIntervalMs = 15 * 60 * 1000; // 15 minutes
81
+
68
82
  @NonNull
69
83
  private final LiveUpdateConfig config;
70
84
 
71
- private final String defaultWebAssetDir;
72
-
73
- @NonNull
74
- private final String host;
85
+ private final String defaultWebAssetDir = Bridge.DEFAULT_WEB_ASSET_DIR;
75
86
 
76
87
  @NonNull
77
88
  private final LiveUpdateHttpClient httpClient;
@@ -89,44 +100,44 @@ public class LiveUpdate {
89
100
  private final Handler rollbackHandler = new Handler();
90
101
  private final String manifestFileName = "capawesome-live-update-manifest.json"; // DO NOT CHANGE!
91
102
 
103
+ private boolean initialPageLoaded = false;
104
+ private long lastAutoUpdateCheckTimestamp = 0;
105
+ private boolean rollbackPerformed = false;
106
+ private boolean syncInProgress = false;
107
+
92
108
  public LiveUpdate(@NonNull LiveUpdateConfig config, @NonNull LiveUpdatePlugin plugin) throws PackageManager.NameNotFoundException {
93
109
  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
110
  this.httpClient = new LiveUpdateHttpClient(config);
101
111
  this.plugin = plugin;
102
112
  this.preferences = new LiveUpdatePreferences(plugin.getContext());
103
113
  this.webViewSettingsEditor = plugin.getContext().getSharedPreferences(WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE).edit();
104
114
 
105
- if (config.getEnabled()) {
106
- if (wasUpdated() && config.getResetOnUpdate()) {
107
- Logger.debug(LiveUpdatePlugin.TAG, "App was updated. Resetting to default bundle.");
108
- reset();
109
- } else {
110
- startRollbackTimer();
111
- }
112
- saveCurrentVersionCode();
113
- }
115
+ // Check version and reset config if version changed
116
+ checkAndResetConfigIfVersionChanged();
117
+
118
+ // Start the rollback timer to rollback to the default bundle
119
+ // if the app is not ready after a certain time
120
+ startRollbackTimer();
121
+ }
122
+
123
+ public void clearBlockedBundles() {
124
+ preferences.setBlockedBundleIds(null);
114
125
  }
115
126
 
116
127
  public void deleteBundle(@NonNull DeleteBundleOptions options, @NonNull EmptyCallback callback) {
117
128
  String bundleId = options.getBundleId();
118
129
 
119
- if (!hasBundle(bundleId)) {
130
+ if (!hasBundleById(bundleId)) {
120
131
  Exception exception = new Exception(LiveUpdatePlugin.ERROR_BUNDLE_NOT_FOUND);
121
132
  callback.error(exception);
122
133
  return;
123
134
  }
124
- deleteBundle(bundleId);
135
+ deleteBundleById(bundleId);
125
136
 
126
137
  callback.success();
127
138
  }
128
139
 
129
- public void downloadBundle(@NonNull DownloadBundleOptions options, @NonNull EmptyCallback callback) throws Exception {
140
+ public void downloadBundle(@NonNull DownloadBundleOptions options, @NonNull EmptyCallback callback) {
130
141
  ArtifactType artifactType = options.getArtifactType();
131
142
  String bundleId = options.getBundleId();
132
143
  String checksum = options.getChecksum();
@@ -134,7 +145,7 @@ public class LiveUpdate {
134
145
  String url = options.getUrl();
135
146
 
136
147
  // Check if the bundle already exists
137
- if (hasBundle(bundleId)) {
148
+ if (hasBundleById(bundleId)) {
138
149
  Exception exception = new Exception(LiveUpdatePlugin.ERROR_BUNDLE_EXISTS);
139
150
  callback.error(exception);
140
151
  return;
@@ -142,50 +153,82 @@ public class LiveUpdate {
142
153
 
143
154
  // Download the bundle
144
155
  if (artifactType == ArtifactType.MANIFEST) {
145
- downloadBundleOfTypeManifest(bundleId, url);
156
+ downloadBundleOfTypeManifest(bundleId, url, callback);
146
157
  } else {
147
- downloadBundleOfTypeZip(bundleId, url, checksum, signature);
158
+ downloadBundleOfTypeZip(bundleId, checksum, signature, url, callback);
148
159
  }
149
- callback.success();
150
160
  }
151
161
 
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);
160
- callback.success(result);
162
+ public void fetchLatestBundle(@NonNull FetchLatestBundleOptions options, @NonNull NonEmptyCallback<FetchLatestBundleResult> callback) {
163
+ fetchLatestBundleInternal(
164
+ options,
165
+ new NonEmptyCallback<GetLatestBundleResponse>() {
166
+ @Override
167
+ public void success(@Nullable GetLatestBundleResponse response) {
168
+ ArtifactType artifactType = response == null ? null : response.getArtifactType();
169
+ String bundleId = response == null ? null : response.getBundleId();
170
+ String checksum = response == null ? null : response.getChecksum();
171
+ JSONObject customProperties = response == null ? null : response.getCustomProperties();
172
+ String downloadUrl = response == null ? null : response.getUrl();
173
+ String signature = response == null ? null : response.getSignature();
174
+ FetchLatestBundleResult result = new FetchLatestBundleResult(
175
+ artifactType,
176
+ bundleId,
177
+ checksum,
178
+ customProperties,
179
+ downloadUrl,
180
+ signature
181
+ );
182
+ callback.success(result);
183
+ }
184
+
185
+ @Override
186
+ public void error(@NonNull Exception exception) {
187
+ callback.error(exception);
188
+ }
189
+ }
190
+ );
161
191
  }
162
192
 
163
- public void getBundle(@NonNull NonEmptyCallback callback) {
164
- String bundleId = getCurrentBundleId();
165
- if (bundleId.equals(defaultWebAssetDir)) {
166
- bundleId = null;
193
+ public void getBlockedBundles(@NonNull NonEmptyCallback<GetBlockedBundlesResult> callback) {
194
+ String blockedIds = preferences.getBlockedBundleIds();
195
+ String[] bundleIds;
196
+ if (blockedIds == null || blockedIds.isEmpty()) {
197
+ bundleIds = new String[0];
198
+ } else {
199
+ bundleIds = blockedIds.split(",");
167
200
  }
168
- GetBundleResult result = new GetBundleResult(bundleId);
201
+ GetBlockedBundlesResult result = new GetBlockedBundlesResult(bundleIds);
169
202
  callback.success(result);
170
203
  }
171
204
 
172
205
  public void getBundles(@NonNull NonEmptyCallback callback) {
173
- String[] bundleIds = getBundleIds();
206
+ String[] bundleIds = getDownloadedBundleIds();
174
207
  GetBundlesResult result = new GetBundlesResult(bundleIds);
175
208
  callback.success(result);
176
209
  }
177
210
 
211
+ public void getDownloadedBundles(@NonNull NonEmptyCallback<GetDownloadedBundlesResult> callback) {
212
+ String[] bundleIds = getDownloadedBundleIds();
213
+ GetDownloadedBundlesResult result = new GetDownloadedBundlesResult(bundleIds);
214
+ callback.success(result);
215
+ }
216
+
178
217
  public void getChannel(@NonNull NonEmptyCallback callback) {
179
218
  String channel = getChannel();
180
219
  GetChannelResult result = new GetChannelResult(channel);
181
220
  callback.success(result);
182
221
  }
183
222
 
223
+ public void getConfig(@NonNull NonEmptyCallback<GetConfigResult> callback) {
224
+ String appId = getAppId();
225
+ String autoUpdateStrategy = config.getAutoUpdateStrategy();
226
+ GetConfigResult result = new GetConfigResult(appId, autoUpdateStrategy);
227
+ callback.success(result);
228
+ }
229
+
184
230
  public void getCurrentBundle(@NonNull NonEmptyCallback<GetCurrentBundleResult> callback) {
185
231
  String bundleId = getCurrentBundleId();
186
- if (bundleId.equals(defaultWebAssetDir)) {
187
- bundleId = null;
188
- }
189
232
  GetCurrentBundleResult result = new GetCurrentBundleResult(bundleId);
190
233
  callback.success(result);
191
234
  }
@@ -204,15 +247,12 @@ public class LiveUpdate {
204
247
 
205
248
  public void getNextBundle(@NonNull NonEmptyCallback<GetNextBundleResult> callback) {
206
249
  String bundleId = getNextBundleId();
207
- if (bundleId.equals(defaultWebAssetDir)) {
208
- bundleId = null;
209
- }
210
250
  GetNextBundleResult result = new GetNextBundleResult(bundleId);
211
251
  callback.success(result);
212
252
  }
213
253
 
214
254
  public void getVersionCode(@NonNull NonEmptyCallback callback) throws PackageManager.NameNotFoundException {
215
- String versionCode = getVersionCode();
255
+ String versionCode = getVersionCodeAsString();
216
256
  GetVersionCodeResult result = new GetVersionCodeResult(versionCode);
217
257
  callback.success(result);
218
258
  }
@@ -223,14 +263,51 @@ public class LiveUpdate {
223
263
  callback.success(result);
224
264
  }
225
265
 
226
- public void ready() {
266
+ public void isSyncing(@NonNull NonEmptyCallback<IsSyncingResult> callback) {
267
+ IsSyncingResult result = new IsSyncingResult(syncInProgress);
268
+ callback.success(result);
269
+ }
270
+
271
+ public void handleOnPageLoaded() {
272
+ // Wait for initial page load to perform auto update to make sure the WebViewLocalServer is initialized.
273
+ // Otherwise, a NullPointerException may occur for `com.getcapacitor.WebViewLocalServer.getBasePath()`.
274
+ if ("background".equals(config.getAutoUpdateStrategy()) && !initialPageLoaded) {
275
+ initialPageLoaded = true;
276
+ performAutoUpdate();
277
+ }
278
+ }
279
+
280
+ public void handleOnResume() {
281
+ if ("background".equals(config.getAutoUpdateStrategy()) && initialPageLoaded) {
282
+ performAutoUpdate();
283
+ }
284
+ }
285
+
286
+ public void ready(@NonNull NonEmptyCallback callback) {
227
287
  Logger.debug(LiveUpdatePlugin.TAG, "App is ready.");
288
+ if (config.getReadyTimeout() <= 0) {
289
+ Logger.warn(LiveUpdatePlugin.TAG, "Ready timeout is set to 0. Automatic rollback is disabled.");
290
+ }
228
291
  // Stop the rollback timer
229
292
  stopRollbackTimer();
230
293
  // Delete unused bundles
231
294
  if (config.getAutoDeleteBundles()) {
232
295
  deleteUnusedBundles();
233
296
  }
297
+ // Get the current and previous bundle IDs
298
+ String currentBundleId = getCurrentBundleId();
299
+ String previousBundleId = getPreviousBundleId();
300
+ // Block the rolled back bundle if enabled
301
+ if (config.getAutoBlockRolledBackBundles() && rollbackPerformed && previousBundleId != null) {
302
+ addBlockedBundleId(previousBundleId);
303
+ }
304
+ // Return the result
305
+ ReadyResult result = new ReadyResult(currentBundleId, previousBundleId, rollbackPerformed);
306
+ callback.success(result);
307
+ // Set the new previous bundle ID
308
+ setPreviousBundleId(currentBundleId);
309
+ // Reset the rollback flag
310
+ rollbackPerformed = false;
234
311
  }
235
312
 
236
313
  public void reload() {
@@ -240,20 +317,11 @@ public class LiveUpdate {
240
317
  }
241
318
 
242
319
  public void reset() {
243
- setNextCapacitorServerPathToDefaultWebAssetDir();
320
+ setNextBundleById(null);
244
321
  }
245
322
 
246
- public void setBundle(@NonNull SetBundleOptions options, @NonNull EmptyCallback callback) {
247
- String bundleId = options.getBundleId();
248
-
249
- if (!hasBundle(bundleId)) {
250
- Exception exception = new Exception(LiveUpdatePlugin.ERROR_BUNDLE_NOT_FOUND);
251
- callback.error(exception);
252
- return;
253
- }
254
-
255
- setNextBundle(bundleId);
256
- callback.success();
323
+ public void resetConfig() {
324
+ preferences.setAppId(null);
257
325
  }
258
326
 
259
327
  public void setChannel(@NonNull SetChannelOptions options, @NonNull EmptyCallback callback) {
@@ -263,6 +331,11 @@ public class LiveUpdate {
263
331
  callback.success();
264
332
  }
265
333
 
334
+ public void setConfig(@NonNull SetConfigOptions options) {
335
+ String appId = options.getAppId();
336
+ preferences.setAppId(appId);
337
+ }
338
+
266
339
  public void setCustomId(@NonNull SetCustomIdOptions options, @NonNull EmptyCallback callback) {
267
340
  String customId = options.getCustomId();
268
341
 
@@ -276,8 +349,8 @@ public class LiveUpdate {
276
349
  if (bundleId == null) {
277
350
  reset();
278
351
  } else {
279
- if (hasBundle(bundleId)) {
280
- setNextBundle(bundleId);
352
+ if (hasBundleById(bundleId)) {
353
+ setNextBundleById(bundleId);
281
354
  } else {
282
355
  Exception exception = new Exception(LiveUpdatePlugin.ERROR_BUNDLE_NOT_FOUND);
283
356
  callback.error(exception);
@@ -287,45 +360,99 @@ public class LiveUpdate {
287
360
  callback.success();
288
361
  }
289
362
 
290
- public void sync(@NonNull SyncOptions options, @NonNull NonEmptyCallback<Result> callback) throws Exception {
363
+ public void sync(@NonNull SyncOptions options, @NonNull NonEmptyCallback<Result> callback) {
364
+ if (syncInProgress) {
365
+ Exception exception = new Exception(LiveUpdatePlugin.ERROR_SYNC_IN_PROGRESS);
366
+ callback.error(exception);
367
+ return;
368
+ }
369
+ syncInProgress = true;
370
+
291
371
  String channel = options.getChannel();
292
372
  // Fetch the latest bundle
293
373
  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;
374
+ fetchLatestBundleInternal(
375
+ fetchLatestBundleOptions,
376
+ new NonEmptyCallback<GetLatestBundleResponse>() {
377
+ @Override
378
+ public void success(@Nullable GetLatestBundleResponse response) {
379
+ try {
380
+ if (response == null) {
381
+ Logger.debug(LiveUpdatePlugin.TAG, "No update available.");
382
+ syncInProgress = false;
383
+ SyncResult syncResult = new SyncResult(null);
384
+ callback.success(syncResult);
385
+ return;
386
+ }
387
+ ArtifactType artifactType = response.getArtifactType();
388
+ String latestBundleId = response.getBundleId();
389
+ String checksum = response.getChecksum();
390
+ String signature = response.getSignature();
391
+ String url = response.getUrl();
392
+ // Check if the bundle is blocked
393
+ if (isBlockedBundleId(latestBundleId)) {
394
+ Logger.warn(LiveUpdatePlugin.TAG, "Bundle is blocked and will not be downloaded.");
395
+ syncInProgress = false;
396
+ SyncResult syncResult = new SyncResult(null);
397
+ callback.success(syncResult);
398
+ return;
399
+ }
400
+ // Check if the bundle already exists
401
+ if (hasBundleById(latestBundleId)) {
402
+ String nextBundleId = null;
403
+ String currentBundleId = getCurrentBundleId();
404
+ if (!latestBundleId.equals(currentBundleId)) {
405
+ // Set the next bundle
406
+ setNextBundleById(latestBundleId);
407
+ nextBundleId = latestBundleId;
408
+ }
409
+ syncInProgress = false;
410
+ SyncResult syncResult = new SyncResult(nextBundleId);
411
+ callback.success(syncResult);
412
+ return;
413
+ }
414
+
415
+ // Download the bundle
416
+ EmptyCallback downloadCallback = new EmptyCallback() {
417
+ @Override
418
+ public void success() {
419
+ try {
420
+ // Set the next bundle
421
+ setNextBundleById(latestBundleId);
422
+ syncInProgress = false;
423
+ SyncResult syncResult = new SyncResult(latestBundleId);
424
+ callback.success(syncResult);
425
+ } catch (Exception e) {
426
+ syncInProgress = false;
427
+ callback.error(e);
428
+ }
429
+ }
430
+
431
+ @Override
432
+ public void error(@NonNull Exception exception) {
433
+ syncInProgress = false;
434
+ callback.error(exception);
435
+ }
436
+ };
437
+
438
+ if (artifactType == ArtifactType.MANIFEST) {
439
+ downloadBundleOfTypeManifest(latestBundleId, url, downloadCallback);
440
+ } else {
441
+ downloadBundleOfTypeZip(latestBundleId, checksum, signature, url, downloadCallback);
442
+ }
443
+ } catch (Exception e) {
444
+ syncInProgress = false;
445
+ callback.error(e);
446
+ }
447
+ }
448
+
449
+ @Override
450
+ public void error(@NonNull Exception exception) {
451
+ syncInProgress = false;
452
+ callback.error(exception);
453
+ }
314
454
  }
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);
455
+ );
329
456
  }
330
457
 
331
458
  private void addBundle(@NonNull String bundleId, @NonNull File sourceDirectory) throws Exception {
@@ -372,9 +499,10 @@ public class LiveUpdate {
372
499
  return new File(plugin.getContext().getCacheDir(), fileName);
373
500
  }
374
501
 
375
- private void copyCurrentBundleFile(@NonNull String href, @NonNull File destinationDirectory) throws IOException {
502
+ private void copyCurrentBundleFile(@NonNull ManifestItem fileToCopy, @NonNull File destinationDirectory) throws IOException {
503
+ String href = fileToCopy.getHref();
376
504
  String currentBundleId = getCurrentBundleId();
377
- if (currentBundleId.equals(defaultWebAssetDir)) {
505
+ if (currentBundleId == null) {
378
506
  // Create the source input stream
379
507
  AssetManager assets = plugin.getContext().getAssets();
380
508
  InputStream inputStream = assets.open(defaultWebAssetDir + "/" + href);
@@ -396,10 +524,20 @@ public class LiveUpdate {
396
524
  }
397
525
  }
398
526
 
399
- private void copyCurrentBundleFiles(@NonNull List<String> hrefs, @NonNull File destinationDirectory) throws IOException {
400
- for (String href : hrefs) {
401
- copyCurrentBundleFile(href, destinationDirectory);
527
+ private List<ManifestItem> copyCurrentBundleFilesAndReturnFailures(
528
+ @NonNull List<ManifestItem> filesToCopy,
529
+ @NonNull File destinationDirectory
530
+ ) {
531
+ List<ManifestItem> missingItems = new ArrayList<>();
532
+ for (ManifestItem fileToCopy : filesToCopy) {
533
+ boolean success = tryCopyCurrentBundleFile(fileToCopy, destinationDirectory);
534
+ if (!success) {
535
+ Logger.warn(LiveUpdatePlugin.TAG, "Failed to copy file: " + fileToCopy.getHref());
536
+ // If the file could not be copied, add it to the list of missing items
537
+ missingItems.add(fileToCopy);
538
+ }
402
539
  }
540
+ return missingItems;
403
541
  }
404
542
 
405
543
  private void copyFile(File input, File output) throws IOException {
@@ -451,14 +589,14 @@ public class LiveUpdate {
451
589
  return file;
452
590
  }
453
591
 
454
- private void deleteBundle(@NonNull String bundleId) {
592
+ private void deleteBundleById(@NonNull String bundleId) {
455
593
  // Delete the bundle directory
456
594
  File bundleDirectory = buildBundleDirectoryFor(bundleId);
457
595
  deleteFileRecursively(bundleDirectory);
458
596
  // Reset the next bundle if it is the deleted bundle
459
597
  String nextBundleId = getNextBundleId();
460
598
  if (bundleId.equals(nextBundleId)) {
461
- setNextCapacitorServerPathToDefaultWebAssetDir();
599
+ setNextBundleById(null);
462
600
  }
463
601
  }
464
602
 
@@ -472,170 +610,357 @@ public class LiveUpdate {
472
610
  }
473
611
 
474
612
  private void deleteUnusedBundles() {
475
- String[] bundleIds = getBundleIds();
613
+ String[] bundleIds = getDownloadedBundleIds();
476
614
  for (String bundleId : bundleIds) {
477
615
  if (!isBundleInUse(bundleId)) {
478
- deleteBundle(bundleId);
616
+ deleteBundleById(bundleId);
479
617
  }
480
618
  }
481
619
  }
482
620
 
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
- }
621
+ private Call downloadAndVerifyFile(
622
+ @NonNull String url,
623
+ @NonNull File file,
624
+ @Nullable String checksum,
625
+ @Nullable String signature,
626
+ @Nullable DownloadProgressCallback progressCallback,
627
+ @NonNull EmptyCallback completionCallback
628
+ ) {
629
+ return httpClient.enqueue(
630
+ url,
631
+ new NonEmptyCallback<Response>() {
632
+ @Override
633
+ public void success(@NonNull Response response) {
634
+ try {
635
+ if (response.isSuccessful()) {
636
+ ResponseBody responseBody = response.body();
637
+ LiveUpdateHttpClient.writeResponseBodyToFile(responseBody, file, progressCallback);
638
+
639
+ // Extract checksum/signature from headers
640
+ String finalChecksum = checksum == null ? LiveUpdateHttpClient.getChecksumFromResponse(response) : checksum;
641
+ String finalSignature = signature == null ? LiveUpdateHttpClient.getSignatureFromResponse(response) : signature;
642
+
643
+ // Verify file
644
+ verifyFile(file, finalChecksum, finalSignature);
645
+ completionCallback.success();
646
+ } else {
647
+ String errorMessage = response.body().string();
648
+ Exception exception = new Exception(LiveUpdatePlugin.ERROR_DOWNLOAD_FAILED);
649
+ Logger.error(LiveUpdatePlugin.TAG, errorMessage, exception);
650
+ completionCallback.error(exception);
651
+ }
652
+ } catch (Exception e) {
653
+ completionCallback.error(e);
654
+ }
655
+ }
656
+
657
+ @Override
658
+ public void error(@NonNull Exception exception) {
659
+ completionCallback.error(exception);
660
+ }
661
+ }
662
+ );
498
663
  }
499
664
 
500
- private File downloadBundleFile(@NonNull String baseUrl, @NonNull String href, @NonNull File destinationDirectory) throws Exception {
665
+ private Call downloadBundleFile(
666
+ @NonNull String baseUrl,
667
+ @NonNull String href,
668
+ @NonNull File destinationDirectory,
669
+ @Nullable DownloadProgressCallback progressCallback,
670
+ @NonNull EmptyCallback completionCallback
671
+ ) {
501
672
  HttpUrl.Builder urlBuilder = HttpUrl.parse(baseUrl).newBuilder();
502
673
  urlBuilder.addQueryParameter("href", href);
503
674
  String url = urlBuilder.build().toString();
504
675
 
505
676
  File destinationFile = new File(destinationDirectory, href);
506
677
  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;
678
+ return downloadAndVerifyFile(url, destinationFile, null, null, progressCallback, completionCallback);
679
+ }
680
+
681
+ private void downloadBundleFiles(
682
+ @NonNull String baseUrl,
683
+ @NonNull List<ManifestItem> filesToDownload,
684
+ @NonNull File destinationDirectory,
685
+ @Nullable DownloadProgressCallback progressCallback,
686
+ @NonNull EmptyCallback completionCallback
687
+ ) {
688
+ if (filesToDownload.isEmpty()) {
689
+ if (progressCallback != null) {
690
+ progressCallback.onProgress(0, 0);
691
+ }
692
+ completionCallback.success();
693
+ return;
694
+ }
695
+
696
+ // Thread-safe progress tracking
697
+ AtomicLong totalBytesDownloaded = new AtomicLong(0);
698
+ long totalBytesToDownload = 0;
699
+ for (ManifestItem item : filesToDownload) {
700
+ totalBytesToDownload += item.getSizeInBytes();
701
+ }
702
+ final long finalTotalBytesToDownload = totalBytesToDownload;
703
+
704
+ // Coordination primitives
705
+ CountDownLatch latch = new CountDownLatch(filesToDownload.size());
706
+ AtomicReference<Exception> firstError = new AtomicReference<>();
707
+ AtomicBoolean completionHandled = new AtomicBoolean(false);
708
+ List<Call> activeCalls = new ArrayList<>();
709
+
710
+ // Start all downloads asynchronously (OkHttp's dispatcher handles parallelization)
711
+ for (ManifestItem item : filesToDownload) {
712
+ Call call = downloadBundleFile(
713
+ baseUrl,
714
+ item.getHref(),
715
+ destinationDirectory,
716
+ (downloadedBytes, totalBytes) -> {
717
+ // Per-file progress
718
+ if (progressCallback != null) {
719
+ long totalProgress = totalBytesDownloaded.get() + downloadedBytes;
720
+ progressCallback.onProgress(totalProgress, finalTotalBytesToDownload);
721
+ }
722
+ },
723
+ new EmptyCallback() {
724
+ @Override
725
+ public void success() {
726
+ // Update total progress
727
+ totalBytesDownloaded.addAndGet(item.getSizeInBytes());
728
+ if (progressCallback != null) {
729
+ progressCallback.onProgress(totalBytesDownloaded.get(), finalTotalBytesToDownload);
730
+ }
731
+ latch.countDown();
732
+
733
+ // Check if this was the last download to complete
734
+ if (latch.getCount() == 0 && completionHandled.compareAndSet(false, true)) {
735
+ Exception error = firstError.get();
736
+ if (error != null) {
737
+ completionCallback.error(error);
738
+ } else {
739
+ // Final progress update
740
+ if (progressCallback != null) {
741
+ progressCallback.onProgress(finalTotalBytesToDownload, finalTotalBytesToDownload);
742
+ }
743
+ completionCallback.success();
744
+ }
745
+ }
746
+ }
747
+
748
+ @Override
749
+ public void error(@NonNull Exception e) {
750
+ // Capture first error and cancel all remaining downloads
751
+ if (firstError.compareAndSet(null, e)) {
752
+ Logger.error(LiveUpdatePlugin.TAG, "Failed to download file: " + item.getHref(), e);
753
+ // Cancel all in-flight downloads (fail-fast)
754
+ synchronized (activeCalls) {
755
+ for (Call activeCall : activeCalls) {
756
+ if (!activeCall.isCanceled() && !activeCall.isExecuted()) {
757
+ activeCall.cancel();
758
+ }
759
+ }
760
+ }
761
+ }
762
+ latch.countDown();
763
+
764
+ // Check if this was the last download to complete
765
+ if (latch.getCount() == 0 && completionHandled.compareAndSet(false, true)) {
766
+ completionCallback.error(firstError.get());
767
+ }
524
768
  }
525
769
  }
526
- // Wait a bit before checking again
527
- Thread.sleep(10);
770
+ );
771
+ synchronized (activeCalls) {
772
+ activeCalls.add(call);
528
773
  }
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
774
  }
573
- // Copy the files
574
- List<String> hrefsToCopy = new ArrayList<>();
575
- for (ManifestItem item : itemsToCopy) {
576
- hrefsToCopy.add(item.getHref());
577
- }
578
- copyCurrentBundleFiles(hrefsToCopy, temporaryDirectory);
579
- // Download the files
580
- List<String> hrefsToDownload = new ArrayList<>();
581
- for (ManifestItem item : itemsToDownload) {
582
- hrefsToDownload.add(item.getHref());
775
+ }
776
+
777
+ private void downloadBundleOfTypeManifest(
778
+ @NonNull String bundleId,
779
+ @NonNull String downloadUrl,
780
+ @NonNull EmptyCallback completionCallback
781
+ ) {
782
+ try {
783
+ // Create a temporary directory
784
+ File temporaryDirectory = createTemporaryDirectory();
785
+
786
+ // Download the latest manifest
787
+ downloadBundleFile(
788
+ downloadUrl,
789
+ manifestFileName,
790
+ temporaryDirectory,
791
+ null,
792
+ new EmptyCallback() {
793
+ @Override
794
+ public void success() {
795
+ try {
796
+ File latestManifestFile = new File(temporaryDirectory, manifestFileName);
797
+ Manifest latestManifest = loadManifest(latestManifestFile);
798
+ // Load the current manifest
799
+ Manifest currentManifest = loadCurrentManifest();
800
+ // Compare the manifests
801
+ List<ManifestItem> itemsToCopy = new ArrayList<>();
802
+ List<ManifestItem> itemsToDownload = new ArrayList<>();
803
+ if (currentManifest == null) {
804
+ itemsToDownload.addAll(latestManifest.getItems());
805
+ } else {
806
+ itemsToCopy.addAll(Manifest.findDuplicateItems(latestManifest, currentManifest));
807
+ itemsToDownload.addAll(Manifest.findMissingItems(latestManifest, currentManifest));
808
+ }
809
+ // Copy the files
810
+ List<ManifestItem> missingItems = copyCurrentBundleFilesAndReturnFailures(itemsToCopy, temporaryDirectory);
811
+ // If items could not be copied, add them to the list of items to download
812
+ if (!missingItems.isEmpty()) {
813
+ itemsToDownload.addAll(missingItems);
814
+ }
815
+
816
+ // Download the files
817
+ downloadBundleFiles(
818
+ downloadUrl,
819
+ itemsToDownload,
820
+ temporaryDirectory,
821
+ (downloadedBytes, totalBytes) -> {
822
+ DownloadBundleProgressEvent event = new DownloadBundleProgressEvent(
823
+ bundleId,
824
+ downloadedBytes,
825
+ totalBytes
826
+ );
827
+ notifyDownloadBundleProgressListeners(event);
828
+ },
829
+ new EmptyCallback() {
830
+ @Override
831
+ public void success() {
832
+ try {
833
+ // Add the bundle
834
+ addBundleOfTypeManifest(bundleId, temporaryDirectory);
835
+ completionCallback.success();
836
+ } catch (Exception e) {
837
+ completionCallback.error(e);
838
+ }
839
+ }
840
+
841
+ @Override
842
+ public void error(@NonNull Exception exception) {
843
+ completionCallback.error(exception);
844
+ }
845
+ }
846
+ );
847
+ } catch (Exception e) {
848
+ completionCallback.error(e);
849
+ }
850
+ }
851
+
852
+ @Override
853
+ public void error(@NonNull Exception exception) {
854
+ completionCallback.error(exception);
855
+ }
856
+ }
857
+ );
858
+ } catch (Exception e) {
859
+ completionCallback.error(e);
583
860
  }
584
- downloadBundleFiles(downloadUrl, hrefsToDownload, temporaryDirectory);
585
- // Add the bundle
586
- addBundleOfTypeManifest(bundleId, temporaryDirectory);
587
861
  }
588
862
 
589
863
  private void downloadBundleOfTypeZip(
590
864
  @NonNull String bundleId,
591
- @NonNull String downloadUrl,
592
865
  @Nullable String checksum,
593
- @Nullable String signature
594
- ) throws Exception {
866
+ @Nullable String signature,
867
+ @NonNull String downloadUrl,
868
+ @NonNull EmptyCallback completionCallback
869
+ ) {
595
870
  File file = buildTemporaryZipFile();
596
871
  // 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();
872
+ downloadAndVerifyFile(
873
+ downloadUrl,
874
+ file,
875
+ checksum,
876
+ signature,
877
+ (downloadedBytes, totalBytes) -> {
878
+ DownloadBundleProgressEvent event = new DownloadBundleProgressEvent(bundleId, downloadedBytes, totalBytes);
879
+ notifyDownloadBundleProgressListeners(event);
880
+ },
881
+ new EmptyCallback() {
882
+ @Override
883
+ public void success() {
884
+ try {
885
+ // Add the bundle
886
+ addBundleOfTypeZip(bundleId, file);
887
+ // Delete the temporary file
888
+ file.delete();
889
+ completionCallback.success();
890
+ } catch (Exception e) {
891
+ completionCallback.error(e);
892
+ }
893
+ }
894
+
895
+ @Override
896
+ public void error(@NonNull Exception exception) {
897
+ // Delete the temporary file on error
898
+ file.delete();
899
+ completionCallback.error(exception);
900
+ }
901
+ }
902
+ );
602
903
  }
603
904
 
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;
905
+ private void fetchLatestBundleInternal(
906
+ @NonNull FetchLatestBundleOptions options,
907
+ @NonNull NonEmptyCallback<GetLatestBundleResponse> callback
908
+ ) {
909
+ try {
910
+ String channel = options.getChannel() == null ? getChannel() : options.getChannel();
911
+ String url = new HttpUrl.Builder()
912
+ .scheme("https")
913
+ .host(config.getServerDomain())
914
+ .addPathSegment("v1")
915
+ .addPathSegment("apps")
916
+ .addPathSegment(getAppId())
917
+ .addPathSegment("bundles")
918
+ .addPathSegment("latest")
919
+ .addQueryParameter("appVersionCode", getVersionCodeAsString())
920
+ .addQueryParameter("appVersionName", getVersionName())
921
+ .addQueryParameter("bundleId", getCurrentBundleId())
922
+ .addQueryParameter("channelName", channel)
923
+ .addQueryParameter("customId", preferences.getCustomId())
924
+ .addQueryParameter("deviceId", getDeviceId())
925
+ .addQueryParameter("osVersion", String.valueOf(Build.VERSION.SDK_INT))
926
+ .addQueryParameter("platform", "0")
927
+ .addQueryParameter("pluginVersion", LiveUpdatePlugin.VERSION)
928
+ .build()
929
+ .toString();
930
+ Logger.debug(LiveUpdatePlugin.TAG, "Fetching latest bundle: " + url);
931
+
932
+ httpClient.enqueue(
933
+ url,
934
+ new NonEmptyCallback<Response>() {
935
+ @Override
936
+ public void success(@NonNull Response response) {
937
+ try {
938
+ String responseBodyString = response.body().string();
939
+ Logger.debug(LiveUpdatePlugin.TAG, "Latest bundle response: " + responseBodyString);
940
+ if (response.isSuccessful()) {
941
+ JSONObject responseJson = new JSONObject(responseBodyString);
942
+ GetLatestBundleResponse result = new GetLatestBundleResponse(responseJson);
943
+ callback.success(result);
944
+ } else {
945
+ callback.success(null);
946
+ }
947
+ } catch (Exception e) {
948
+ callback.error(e);
949
+ }
950
+ }
951
+
952
+ @Override
953
+ public void error(@NonNull Exception exception) {
954
+ callback.error(exception);
955
+ }
956
+ }
957
+ );
958
+ } catch (Exception e) {
959
+ callback.error(e);
635
960
  }
636
961
  }
637
962
 
638
- private String[] getBundleIds() {
963
+ private String[] getDownloadedBundleIds() {
639
964
  File bundlesDirectory = buildBundlesDirectory();
640
965
  File[] bundles = bundlesDirectory.listFiles();
641
966
  if (bundles == null) {
@@ -675,6 +1000,15 @@ public class LiveUpdate {
675
1000
  return checksum.toString();
676
1001
  }
677
1002
 
1003
+ @Nullable
1004
+ private String getAppId() {
1005
+ String appId = preferences.getAppId();
1006
+ if (appId != null) {
1007
+ return appId;
1008
+ }
1009
+ return config.getAppId();
1010
+ }
1011
+
678
1012
  @Nullable
679
1013
  private String getChannel() {
680
1014
  String channel = null;
@@ -688,12 +1022,13 @@ public class LiveUpdate {
688
1022
  }
689
1023
 
690
1024
  /**
691
- * @return The current bundle ID (`public` for the built-in bundle).
1025
+ * @return The current bundle ID or `null` if the default bundle is in use.
692
1026
  */
1027
+ @Nullable
693
1028
  private String getCurrentBundleId() {
694
1029
  String currentPath = getCurrentCapacitorServerPath();
695
1030
  if (currentPath.equals(defaultWebAssetDir)) {
696
- return defaultWebAssetDir;
1031
+ return null;
697
1032
  }
698
1033
  return new File(currentPath).getName();
699
1034
  }
@@ -707,22 +1042,22 @@ public class LiveUpdate {
707
1042
 
708
1043
  @NonNull
709
1044
  private String getDeviceId() {
710
- String deviceId = preferences.getDeviceIdForApp(config.getAppId());
1045
+ String deviceId = preferences.getDeviceIdForApp(getAppId());
711
1046
  if (deviceId == null) {
712
1047
  deviceId = UUID.randomUUID().toString().toLowerCase();
713
- preferences.setDeviceIdForApp(config.getAppId(), deviceId);
1048
+ preferences.setDeviceIdForApp(getAppId(), deviceId);
714
1049
  }
715
1050
  return deviceId;
716
1051
  }
717
1052
 
718
1053
  /**
719
- * @return The next bundle ID (`public` for the built-in bundle).
1054
+ * @return The next bundle ID or `null` if the default bundle will be used.
720
1055
  */
721
- @NonNull
1056
+ @Nullable
722
1057
  private String getNextBundleId() {
723
1058
  String nextPath = getNextCapacitorServerPath();
724
1059
  if (nextPath.equals(defaultWebAssetDir)) {
725
- return defaultWebAssetDir;
1060
+ return null;
726
1061
  }
727
1062
  return new File(nextPath).getName();
728
1063
  }
@@ -732,31 +1067,54 @@ public class LiveUpdate {
732
1067
  */
733
1068
  @NonNull
734
1069
  private String getNextCapacitorServerPath() {
735
- return plugin
1070
+ String path = plugin
736
1071
  .getContext()
737
1072
  .getSharedPreferences(WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE)
738
1073
  .getString(WebView.CAP_SERVER_PATH, defaultWebAssetDir);
1074
+ // Empty path means default path
1075
+ if (path.isEmpty()) {
1076
+ path = defaultWebAssetDir;
1077
+ }
1078
+ return path;
739
1079
  }
740
1080
 
741
- private String getVersionCode() throws PackageManager.NameNotFoundException {
742
- return String.valueOf(getPackageInfo().versionCode);
1081
+ /**
1082
+ * @return The previous bundle ID or `null` if the default bundle was used.
1083
+ */
1084
+ @Nullable
1085
+ private String getPreviousBundleId() {
1086
+ return preferences.getPreviousBundleId();
1087
+ }
1088
+
1089
+ private int getVersionCodeAsInt() throws PackageManager.NameNotFoundException {
1090
+ return getPackageInfo().versionCode;
1091
+ }
1092
+
1093
+ private String getVersionCodeAsString() throws PackageManager.NameNotFoundException {
1094
+ return String.valueOf(getVersionCodeAsInt());
743
1095
  }
744
1096
 
745
1097
  private String getVersionName() throws PackageManager.NameNotFoundException {
746
1098
  return getPackageInfo().versionName;
747
1099
  }
748
1100
 
749
- private boolean hasBundle(@NonNull String bundleId) {
1101
+ private boolean hasBundleById(@NonNull String bundleId) {
750
1102
  File bundleDirectory = buildBundleDirectoryFor(bundleId);
751
1103
  return bundleDirectory.exists();
752
1104
  }
753
1105
 
1106
+ private boolean isBundleInUse(@NonNull String bundleId) {
1107
+ String currentBundleId = getCurrentBundleId();
1108
+ String nextBundleId = getNextBundleId();
1109
+ return bundleId.equals(currentBundleId) || bundleId.equals(nextBundleId);
1110
+ }
1111
+
754
1112
  @Nullable
755
1113
  private Manifest loadCurrentManifest() throws Exception {
756
1114
  String currentBundleId = getCurrentBundleId();
757
- if (currentBundleId.equals(defaultWebAssetDir)) {
1115
+ if (currentBundleId == null) {
758
1116
  AssetManager assets = plugin.getContext().getAssets();
759
- Boolean manifestFileExists = Arrays.asList(assets.list(defaultWebAssetDir)).contains(manifestFileName);
1117
+ boolean manifestFileExists = Arrays.asList(assets.list(defaultWebAssetDir)).contains(manifestFileName);
760
1118
  if (manifestFileExists) {
761
1119
  InputStream inputStream = assets.open(defaultWebAssetDir + "/" + manifestFileName);
762
1120
  BufferedSource source = Okio.buffer(Okio.source(inputStream));
@@ -786,25 +1144,53 @@ public class LiveUpdate {
786
1144
  return loadManifest(source);
787
1145
  }
788
1146
 
789
- private boolean isBundleInUse(@NonNull String bundleId) {
790
- String currentBundleId = getCurrentBundleId();
791
- String nextBundleId = getNextBundleId();
792
- return bundleId.equals(currentBundleId) || bundleId.equals(nextBundleId);
1147
+ private void notifyDownloadBundleProgressListeners(@NonNull final DownloadBundleProgressEvent event) {
1148
+ plugin.notifyDownloadBundleProgressListeners(event);
793
1149
  }
794
1150
 
795
- private void rollback() {
796
- if (getCurrentBundleId() == defaultWebAssetDir) {
797
- Logger.debug(LiveUpdatePlugin.TAG, "App is not ready. Default bundle is already in use.");
1151
+ private void performAutoUpdate() {
1152
+ // Check if enough time has passed since the last check
1153
+ long now = System.currentTimeMillis();
1154
+ if (lastAutoUpdateCheckTimestamp > 0 && (now - lastAutoUpdateCheckTimestamp) < autoUpdateIntervalMs) {
1155
+ Logger.debug(LiveUpdatePlugin.TAG, "Auto-update skipped. Last check was less than 15 minutes ago.");
798
1156
  return;
799
1157
  }
800
- Logger.debug(LiveUpdatePlugin.TAG, "App is not ready. Rolling back to default bundle.");
801
- setNextCapacitorServerPathToDefaultWebAssetDir();
802
- setCurrentCapacitorServerPathToDefaultWebAssetDir();
1158
+
1159
+ // Update the timestamp
1160
+ lastAutoUpdateCheckTimestamp = now;
1161
+
1162
+ // Run sync
1163
+ Logger.debug(LiveUpdatePlugin.TAG, "Auto-update started.");
1164
+ SyncOptions options = new SyncOptions((String) null);
1165
+ NonEmptyCallback<Result> callback = new NonEmptyCallback<>() {
1166
+ @Override
1167
+ public void success(@NonNull Result result) {
1168
+ Logger.debug(LiveUpdatePlugin.TAG, "Auto-update completed successfully.");
1169
+ }
1170
+
1171
+ @Override
1172
+ public void error(@NonNull Exception exception) {
1173
+ Logger.error(LiveUpdatePlugin.TAG, "Auto-update failed: " + exception.getMessage(), exception);
1174
+ }
1175
+ };
1176
+ sync(options, callback);
803
1177
  }
804
1178
 
805
- private void saveCurrentVersionCode() throws PackageManager.NameNotFoundException {
806
- int currentVersionCode = getPackageInfo().versionCode;
807
- preferences.setLastVersionCode(currentVersionCode);
1179
+ private void rollback() {
1180
+ // Set the rollback flag
1181
+ rollbackPerformed = true;
1182
+ // Set the new previous bundle ID
1183
+ String currentBundleId = getCurrentBundleId();
1184
+ setPreviousBundleId(currentBundleId);
1185
+ // Log the rollback result
1186
+ if (currentBundleId == null) {
1187
+ Logger.debug(LiveUpdatePlugin.TAG, "App is not ready. Default bundle is already in use.");
1188
+ } else {
1189
+ Logger.debug(LiveUpdatePlugin.TAG, "App is not ready. Rolling back to default bundle.");
1190
+ // Rollback to the default bundle
1191
+ setNextBundleById(null);
1192
+ setCurrentBundleById(null);
1193
+ }
808
1194
  }
809
1195
 
810
1196
  @Nullable
@@ -832,6 +1218,18 @@ public class LiveUpdate {
832
1218
  return null;
833
1219
  }
834
1220
 
1221
+ /**
1222
+ * @param bundleId The bundle ID to set as the current bundle. If `null`, the default bundle will be used.
1223
+ */
1224
+ private void setCurrentBundleById(@Nullable String bundleId) {
1225
+ if (bundleId == null) {
1226
+ setCurrentCapacitorServerPath(defaultWebAssetDir);
1227
+ } else {
1228
+ File bundleDirectory = buildBundleDirectoryFor(bundleId);
1229
+ setCurrentCapacitorServerPath(bundleDirectory.getPath());
1230
+ }
1231
+ }
1232
+
835
1233
  private void setCurrentCapacitorServerPath(@NonNull String path) {
836
1234
  if (path.equals(defaultWebAssetDir)) {
837
1235
  this.plugin.getBridge().setServerAssetPath(path);
@@ -839,15 +1237,72 @@ public class LiveUpdate {
839
1237
  this.plugin.getBridge().setServerBasePath(path);
840
1238
  }
841
1239
  this.plugin.getBridge().reload();
1240
+ // Notify listeners
1241
+ notifyReloadedListeners();
1242
+ }
1243
+
1244
+ /**
1245
+ * @param bundleId The bundle ID to set as the next bundle. If `null`, the default bundle will be used.
1246
+ */
1247
+ private void setNextBundleById(@Nullable String bundleId) {
1248
+ if (bundleId == null) {
1249
+ setNextCapacitorServerPath(defaultWebAssetDir);
1250
+ } else {
1251
+ File bundleDirectory = buildBundleDirectoryFor(bundleId);
1252
+ setNextCapacitorServerPath(bundleDirectory.getPath());
1253
+ }
1254
+
1255
+ // Notify listeners
1256
+ notifyNextBundleSetListeners(bundleId);
842
1257
  }
843
1258
 
844
- private void setCurrentCapacitorServerPathToDefaultWebAssetDir() {
845
- setCurrentCapacitorServerPath(defaultWebAssetDir);
1259
+ private void notifyNextBundleSetListeners(@Nullable String bundleId) {
1260
+ NextBundleSetEvent event = new NextBundleSetEvent(bundleId);
1261
+ plugin.notifyNextBundleSetListeners(event);
846
1262
  }
847
1263
 
848
- private void setNextBundle(@NonNull String bundleId) {
849
- File bundleDirectory = buildBundleDirectoryFor(bundleId);
850
- setNextCapacitorServerPath(bundleDirectory.getPath());
1264
+ private void notifyReloadedListeners() {
1265
+ plugin.notifyReloadedListeners();
1266
+ }
1267
+
1268
+ private void addBlockedBundleId(@NonNull String bundleId) {
1269
+ String blockedIds = preferences.getBlockedBundleIds();
1270
+ List<String> blockedList = new ArrayList<>();
1271
+
1272
+ // Parse existing blocked IDs
1273
+ if (blockedIds != null && !blockedIds.isEmpty()) {
1274
+ String[] ids = blockedIds.split(",");
1275
+ blockedList.addAll(Arrays.asList(ids));
1276
+ }
1277
+
1278
+ // Skip if already blocked
1279
+ if (blockedList.contains(bundleId)) {
1280
+ return;
1281
+ }
1282
+
1283
+ // Remove oldest if limit reached
1284
+ if (blockedList.size() >= 100) {
1285
+ blockedList.remove(0);
1286
+ }
1287
+
1288
+ // Add new bundle
1289
+ blockedList.add(bundleId);
1290
+
1291
+ // Save back to preferences
1292
+ String newBlockedIds = String.join(",", blockedList);
1293
+ preferences.setBlockedBundleIds(newBlockedIds);
1294
+
1295
+ Logger.debug(LiveUpdatePlugin.TAG, "Bundle blocked: " + bundleId);
1296
+ }
1297
+
1298
+ private boolean isBlockedBundleId(@NonNull String bundleId) {
1299
+ String blockedIds = preferences.getBlockedBundleIds();
1300
+ if (blockedIds == null || blockedIds.isEmpty()) {
1301
+ return false;
1302
+ }
1303
+
1304
+ String[] ids = blockedIds.split(",");
1305
+ return Arrays.asList(ids).contains(bundleId);
851
1306
  }
852
1307
 
853
1308
  private void setNextCapacitorServerPath(@NonNull String path) {
@@ -855,11 +1310,28 @@ public class LiveUpdate {
855
1310
  this.webViewSettingsEditor.commit();
856
1311
  }
857
1312
 
858
- private void setNextCapacitorServerPathToDefaultWebAssetDir() {
859
- setNextCapacitorServerPath(defaultWebAssetDir);
1313
+ private void checkAndResetConfigIfVersionChanged() throws PackageManager.NameNotFoundException {
1314
+ int currentVersionCode = getVersionCodeAsInt();
1315
+ int lastVersionCode = preferences.getLastVersionCode();
1316
+
1317
+ if (lastVersionCode == -1 || lastVersionCode != currentVersionCode) {
1318
+ Logger.debug(
1319
+ LiveUpdatePlugin.TAG,
1320
+ "App version changed (last: " + lastVersionCode + ", current: " + currentVersionCode + "), resetting config."
1321
+ );
1322
+ resetConfig();
1323
+ preferences.setLastVersionCode(currentVersionCode);
1324
+ }
1325
+ }
1326
+
1327
+ private void setPreviousBundleId(@Nullable String bundleId) {
1328
+ preferences.setPreviousBundleId(bundleId);
860
1329
  }
861
1330
 
862
1331
  private void startRollbackTimer() {
1332
+ if (config.getReadyTimeout() <= 0) {
1333
+ return;
1334
+ }
863
1335
  stopRollbackTimer();
864
1336
  rollbackHandler.postDelayed(() -> rollback(), config.getReadyTimeout());
865
1337
  }
@@ -868,6 +1340,15 @@ public class LiveUpdate {
868
1340
  rollbackHandler.removeCallbacksAndMessages(null);
869
1341
  }
870
1342
 
1343
+ private boolean tryCopyCurrentBundleFile(@NonNull ManifestItem fileToCopy, @NonNull File destinationDirectory) {
1344
+ try {
1345
+ copyCurrentBundleFile(fileToCopy, destinationDirectory);
1346
+ return true;
1347
+ } catch (IOException exception) {
1348
+ return false;
1349
+ }
1350
+ }
1351
+
871
1352
  private File unzipFile(@NonNull File zipFile) throws IOException {
872
1353
  File destination = buildTemporaryDirectory();
873
1354
  String destinationPath = destination.getPath();
@@ -926,12 +1407,6 @@ public class LiveUpdate {
926
1407
  }
927
1408
  }
928
1409
 
929
- private boolean wasUpdated() throws PackageManager.NameNotFoundException {
930
- int lastVersionCode = preferences.getLastVersionCode();
931
- int currentVersionCode = getPackageInfo().versionCode;
932
- return lastVersionCode != currentVersionCode;
933
- }
934
-
935
1410
  private PackageInfo getPackageInfo() throws PackageManager.NameNotFoundException {
936
1411
  String packageName = this.plugin.getContext().getPackageName();
937
1412
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {