@capawesome/capacitor-live-update 6.7.2 → 7.0.0-dev.1765885034

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 +3 -3
  2. package/Package.swift +32 -0
  3. package/README.md +419 -83
  4. package/android/build.gradle +12 -12
  5. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +758 -273
  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 +212 -68
  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 +27 -0
  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/DownloadBundleOptions.java +16 -1
  14. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/SetConfigOptions.java +19 -0
  15. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/SyncOptions.java +4 -0
  16. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/FetchLatestBundleResult.java +29 -1
  17. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetBlockedBundlesResult.java +27 -0
  18. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetConfigResult.java +40 -0
  19. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/GetDownloadedBundlesResult.java +27 -0
  20. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/IsSyncingResult.java +26 -0
  21. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/ReadyResult.java +32 -0
  22. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/interfaces/DownloadProgressCallback.java +5 -0
  23. package/dist/docs.json +630 -103
  24. package/dist/esm/definitions.d.ts +365 -39
  25. package/dist/esm/definitions.js.map +1 -1
  26. package/dist/esm/index.js +1 -1
  27. package/dist/esm/index.js.map +1 -1
  28. package/dist/esm/web.d.ts +9 -2
  29. package/dist/esm/web.js +21 -0
  30. package/dist/esm/web.js.map +1 -1
  31. package/dist/plugin.cjs.js +22 -3
  32. package/dist/plugin.cjs.js.map +1 -1
  33. package/dist/plugin.js +22 -3
  34. package/dist/plugin.js.map +1 -1
  35. package/ios/Plugin/Classes/Events/DownloadBundleProgressEvent.swift +25 -0
  36. package/ios/Plugin/Classes/{Results/GetBundleResult.swift → Events/NextBundleSetEvent.swift} +3 -3
  37. package/ios/Plugin/Classes/Options/DownloadBundleOptions.swift +7 -1
  38. package/ios/Plugin/Classes/Options/SetConfigOptions.swift +14 -0
  39. package/ios/Plugin/Classes/Options/SyncOptions.swift +4 -0
  40. package/ios/Plugin/Classes/Results/{FetchLatestBundle.swift → FetchLatestBundleResult.swift} +16 -1
  41. package/ios/Plugin/Classes/Results/GetBlockedBundlesResult.swift +16 -0
  42. package/ios/Plugin/Classes/Results/GetConfigResult.swift +19 -0
  43. package/ios/Plugin/Classes/Results/GetDownloadedBundlesResult.swift +16 -0
  44. package/ios/Plugin/Classes/Results/IsSyncingResult.swift +16 -0
  45. package/ios/Plugin/Classes/Results/ReadyResult.swift +22 -0
  46. package/ios/Plugin/LiveUpdate.swift +370 -151
  47. package/ios/Plugin/LiveUpdateConfig.swift +4 -4
  48. package/ios/Plugin/LiveUpdateHttpClient.swift +10 -4
  49. package/ios/Plugin/LiveUpdatePlugin.swift +138 -35
  50. package/ios/Plugin/LiveUpdatePreferences.swift +56 -14
  51. package/ios/Plugin/Protocols/Api/GetLatestBundleResponse.swift +3 -0
  52. package/package.json +19 -20
  53. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/SetBundleOptions.java +0 -18
  54. package/ios/Plugin/Classes/Options/SetBundleOptions.swift +0 -14
  55. package/ios/Plugin/LiveUpdatePlugin.h +0 -10
  56. 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,51 +100,52 @@ 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();
144
+ String signature = options.getSignature();
133
145
  String url = options.getUrl();
134
146
 
135
147
  // Check if the bundle already exists
136
- if (hasBundle(bundleId)) {
148
+ if (hasBundleById(bundleId)) {
137
149
  Exception exception = new Exception(LiveUpdatePlugin.ERROR_BUNDLE_EXISTS);
138
150
  callback.error(exception);
139
151
  return;
@@ -141,48 +153,82 @@ public class LiveUpdate {
141
153
 
142
154
  // Download the bundle
143
155
  if (artifactType == ArtifactType.MANIFEST) {
144
- downloadBundleOfTypeManifest(bundleId, url);
156
+ downloadBundleOfTypeManifest(bundleId, url, callback);
145
157
  } else {
146
- downloadBundleOfTypeZip(bundleId, url, checksum);
158
+ downloadBundleOfTypeZip(bundleId, checksum, signature, url, callback);
147
159
  }
148
- callback.success();
149
160
  }
150
161
 
151
- public void fetchLatestBundle(@NonNull FetchLatestBundleOptions options, @NonNull NonEmptyCallback callback) throws Exception {
152
- GetLatestBundleResponse response = fetchLatestBundle(options);
153
- ArtifactType artifactType = response == null ? null : response.getArtifactType();
154
- String bundleId = response == null ? null : response.getBundleId();
155
- String downloadUrl = response == null ? null : response.getUrl();
156
- FetchLatestBundleResult result = new FetchLatestBundleResult(artifactType, bundleId, downloadUrl);
157
- 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
+ );
158
191
  }
159
192
 
160
- public void getBundle(@NonNull NonEmptyCallback callback) {
161
- String bundleId = getCurrentBundleId();
162
- if (bundleId.equals(defaultWebAssetDir)) {
163
- 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(",");
164
200
  }
165
- GetBundleResult result = new GetBundleResult(bundleId);
201
+ GetBlockedBundlesResult result = new GetBlockedBundlesResult(bundleIds);
166
202
  callback.success(result);
167
203
  }
168
204
 
169
205
  public void getBundles(@NonNull NonEmptyCallback callback) {
170
- String[] bundleIds = getBundleIds();
206
+ String[] bundleIds = getDownloadedBundleIds();
171
207
  GetBundlesResult result = new GetBundlesResult(bundleIds);
172
208
  callback.success(result);
173
209
  }
174
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
+
175
217
  public void getChannel(@NonNull NonEmptyCallback callback) {
176
218
  String channel = getChannel();
177
219
  GetChannelResult result = new GetChannelResult(channel);
178
220
  callback.success(result);
179
221
  }
180
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
+
181
230
  public void getCurrentBundle(@NonNull NonEmptyCallback<GetCurrentBundleResult> callback) {
182
231
  String bundleId = getCurrentBundleId();
183
- if (bundleId.equals(defaultWebAssetDir)) {
184
- bundleId = null;
185
- }
186
232
  GetCurrentBundleResult result = new GetCurrentBundleResult(bundleId);
187
233
  callback.success(result);
188
234
  }
@@ -201,15 +247,12 @@ public class LiveUpdate {
201
247
 
202
248
  public void getNextBundle(@NonNull NonEmptyCallback<GetNextBundleResult> callback) {
203
249
  String bundleId = getNextBundleId();
204
- if (bundleId.equals(defaultWebAssetDir)) {
205
- bundleId = null;
206
- }
207
250
  GetNextBundleResult result = new GetNextBundleResult(bundleId);
208
251
  callback.success(result);
209
252
  }
210
253
 
211
254
  public void getVersionCode(@NonNull NonEmptyCallback callback) throws PackageManager.NameNotFoundException {
212
- String versionCode = getVersionCode();
255
+ String versionCode = getVersionCodeAsString();
213
256
  GetVersionCodeResult result = new GetVersionCodeResult(versionCode);
214
257
  callback.success(result);
215
258
  }
@@ -220,14 +263,51 @@ public class LiveUpdate {
220
263
  callback.success(result);
221
264
  }
222
265
 
223
- 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) {
224
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
+ }
225
291
  // Stop the rollback timer
226
292
  stopRollbackTimer();
227
293
  // Delete unused bundles
228
294
  if (config.getAutoDeleteBundles()) {
229
295
  deleteUnusedBundles();
230
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;
231
311
  }
232
312
 
233
313
  public void reload() {
@@ -237,20 +317,11 @@ public class LiveUpdate {
237
317
  }
238
318
 
239
319
  public void reset() {
240
- setNextCapacitorServerPathToDefaultWebAssetDir();
320
+ setNextBundleById(null);
241
321
  }
242
322
 
243
- public void setBundle(@NonNull SetBundleOptions options, @NonNull EmptyCallback callback) {
244
- String bundleId = options.getBundleId();
245
-
246
- if (!hasBundle(bundleId)) {
247
- Exception exception = new Exception(LiveUpdatePlugin.ERROR_BUNDLE_NOT_FOUND);
248
- callback.error(exception);
249
- return;
250
- }
251
-
252
- setNextBundle(bundleId);
253
- callback.success();
323
+ public void resetConfig() {
324
+ preferences.setAppId(null);
254
325
  }
255
326
 
256
327
  public void setChannel(@NonNull SetChannelOptions options, @NonNull EmptyCallback callback) {
@@ -260,6 +331,11 @@ public class LiveUpdate {
260
331
  callback.success();
261
332
  }
262
333
 
334
+ public void setConfig(@NonNull SetConfigOptions options) {
335
+ String appId = options.getAppId();
336
+ preferences.setAppId(appId);
337
+ }
338
+
263
339
  public void setCustomId(@NonNull SetCustomIdOptions options, @NonNull EmptyCallback callback) {
264
340
  String customId = options.getCustomId();
265
341
 
@@ -273,8 +349,8 @@ public class LiveUpdate {
273
349
  if (bundleId == null) {
274
350
  reset();
275
351
  } else {
276
- if (hasBundle(bundleId)) {
277
- setNextBundle(bundleId);
352
+ if (hasBundleById(bundleId)) {
353
+ setNextBundleById(bundleId);
278
354
  } else {
279
355
  Exception exception = new Exception(LiveUpdatePlugin.ERROR_BUNDLE_NOT_FOUND);
280
356
  callback.error(exception);
@@ -284,43 +360,99 @@ public class LiveUpdate {
284
360
  callback.success();
285
361
  }
286
362
 
287
- 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
+
288
371
  String channel = options.getChannel();
289
372
  // Fetch the latest bundle
290
373
  FetchLatestBundleOptions fetchLatestBundleOptions = new FetchLatestBundleOptions(channel);
291
- GetLatestBundleResponse response = fetchLatestBundle(fetchLatestBundleOptions);
292
- if (response == null) {
293
- Logger.debug(LiveUpdatePlugin.TAG, "No update available.");
294
- SyncResult syncResult = new SyncResult(null);
295
- callback.success(syncResult);
296
- return;
297
- }
298
- ArtifactType artifactType = response.getArtifactType();
299
- String latestBundleId = response.getBundleId();
300
- String url = response.getUrl();
301
- // Check if the bundle already exists
302
- if (hasBundle(latestBundleId)) {
303
- String nextBundleId = null;
304
- String currentBundleId = getCurrentBundleId();
305
- if (!latestBundleId.equals(currentBundleId)) {
306
- // Set the next bundle
307
- setNextBundle(latestBundleId);
308
- 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
+ }
309
454
  }
310
- SyncResult syncResult = new SyncResult(nextBundleId);
311
- callback.success(syncResult);
312
- return;
313
- }
314
- // Download the bundle
315
- if (artifactType == ArtifactType.MANIFEST) {
316
- downloadBundleOfTypeManifest(latestBundleId, url);
317
- } else {
318
- downloadBundleOfTypeZip(latestBundleId, url, null);
319
- }
320
- // Set the next bundle
321
- setNextBundle(latestBundleId);
322
- SyncResult syncResult = new SyncResult(latestBundleId);
323
- callback.success(syncResult);
455
+ );
324
456
  }
325
457
 
326
458
  private void addBundle(@NonNull String bundleId, @NonNull File sourceDirectory) throws Exception {
@@ -367,9 +499,10 @@ public class LiveUpdate {
367
499
  return new File(plugin.getContext().getCacheDir(), fileName);
368
500
  }
369
501
 
370
- 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();
371
504
  String currentBundleId = getCurrentBundleId();
372
- if (currentBundleId.equals(defaultWebAssetDir)) {
505
+ if (currentBundleId == null) {
373
506
  // Create the source input stream
374
507
  AssetManager assets = plugin.getContext().getAssets();
375
508
  InputStream inputStream = assets.open(defaultWebAssetDir + "/" + href);
@@ -391,10 +524,20 @@ public class LiveUpdate {
391
524
  }
392
525
  }
393
526
 
394
- private void copyCurrentBundleFiles(@NonNull List<String> hrefs, @NonNull File destinationDirectory) throws IOException {
395
- for (String href : hrefs) {
396
- 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
+ }
397
539
  }
540
+ return missingItems;
398
541
  }
399
542
 
400
543
  private void copyFile(File input, File output) throws IOException {
@@ -446,14 +589,14 @@ public class LiveUpdate {
446
589
  return file;
447
590
  }
448
591
 
449
- private void deleteBundle(@NonNull String bundleId) {
592
+ private void deleteBundleById(@NonNull String bundleId) {
450
593
  // Delete the bundle directory
451
594
  File bundleDirectory = buildBundleDirectoryFor(bundleId);
452
595
  deleteFileRecursively(bundleDirectory);
453
596
  // Reset the next bundle if it is the deleted bundle
454
597
  String nextBundleId = getNextBundleId();
455
598
  if (bundleId.equals(nextBundleId)) {
456
- setNextCapacitorServerPathToDefaultWebAssetDir();
599
+ setNextBundleById(null);
457
600
  }
458
601
  }
459
602
 
@@ -467,165 +610,357 @@ public class LiveUpdate {
467
610
  }
468
611
 
469
612
  private void deleteUnusedBundles() {
470
- String[] bundleIds = getBundleIds();
613
+ String[] bundleIds = getDownloadedBundleIds();
471
614
  for (String bundleId : bundleIds) {
472
615
  if (!isBundleInUse(bundleId)) {
473
- deleteBundle(bundleId);
616
+ deleteBundleById(bundleId);
474
617
  }
475
618
  }
476
619
  }
477
620
 
478
- private void downloadAndVerifyFile(@NonNull String url, @NonNull File file, @Nullable String checksum) throws Exception {
479
- Response response = httpClient.execute(url);
480
- ResponseBody responseBody = response.body();
481
- if (response.isSuccessful()) {
482
- LiveUpdateHttpClient.writeResponseBodyToFile(responseBody, file);
483
- // Verify the file
484
- checksum = checksum == null ? LiveUpdateHttpClient.getChecksumFromResponse(response) : checksum;
485
- String signature = LiveUpdateHttpClient.getSignatureFromResponse(response);
486
- verifyFile(file, checksum, signature);
487
- } else {
488
- Exception exception = new Exception(responseBody.string());
489
- Logger.error(LiveUpdatePlugin.TAG, exception.getMessage(), exception);
490
- throw new Exception(LiveUpdatePlugin.ERROR_DOWNLOAD_FAILED);
491
- }
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
+ );
492
663
  }
493
664
 
494
- 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
+ ) {
495
672
  HttpUrl.Builder urlBuilder = HttpUrl.parse(baseUrl).newBuilder();
496
673
  urlBuilder.addQueryParameter("href", href);
497
674
  String url = urlBuilder.build().toString();
498
675
 
499
676
  File destinationFile = new File(destinationDirectory, href);
500
677
  destinationFile.getParentFile().mkdirs();
501
- downloadAndVerifyFile(url, destinationFile, null);
502
- return destinationFile;
503
- }
504
-
505
- private void downloadBundleFiles(@NonNull String baseUrl, @NonNull List<String> hrefs, @NonNull File destinationDirectory)
506
- throws Exception {
507
- // Limit the number of threads to twice the number of processors
508
- int maxThreads = Runtime.getRuntime().availableProcessors() * 2;
509
- List<Thread> threads = new ArrayList<>();
510
- AtomicReference<Exception> exceptionReference = new AtomicReference<>();
511
- for (String href : hrefs) {
512
- // Wait until a thread is available
513
- while (threads.size() >= maxThreads) {
514
- for (Thread thread : threads) {
515
- if (!thread.isAlive()) {
516
- threads.remove(thread);
517
- 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
+ }
518
768
  }
519
769
  }
520
- // Wait a bit before checking again
521
- Thread.sleep(10);
522
- }
523
- // Stop if an exception occurred
524
- if (exceptionReference.get() != null) {
525
- break;
770
+ );
771
+ synchronized (activeCalls) {
772
+ activeCalls.add(call);
526
773
  }
527
- // Start a new thread
528
- Thread thread = new Thread(() -> {
529
- try {
530
- downloadBundleFile(baseUrl, href, destinationDirectory);
531
- } catch (Exception exception) {
532
- exceptionReference.set(exception);
533
- Logger.error(LiveUpdatePlugin.TAG, "Failed to download file: " + href, exception);
534
- }
535
- });
536
- threads.add(thread);
537
- thread.start();
538
- }
539
- // Wait for all threads to finish
540
- for (Thread thread : threads) {
541
- thread.join();
542
- }
543
- // Throw the exception if one occurred
544
- Exception exception = exceptionReference.get();
545
- if (exception != null) {
546
- throw exception;
547
- }
548
- }
549
-
550
- private void downloadBundleOfTypeManifest(@NonNull String bundleId, @NonNull String downloadUrl) throws Exception {
551
- // Create a temporary directory
552
- File temporaryDirectory = createTemporaryDirectory();
553
- // Download the latest manifest
554
- File latestManifestFile = downloadBundleFile(downloadUrl, manifestFileName, temporaryDirectory);
555
- Manifest latestManifest = loadManifest(latestManifestFile);
556
- // Load the current manifest
557
- Manifest currentManifest = loadCurrentManifest();
558
- // Compare the manifests
559
- List<ManifestItem> itemsToCopy = new ArrayList<>();
560
- List<ManifestItem> itemsToDownload = new ArrayList<>();
561
- if (currentManifest == null) {
562
- itemsToDownload.addAll(latestManifest.getItems());
563
- } else {
564
- itemsToCopy.addAll(Manifest.findDuplicateItems(latestManifest, currentManifest));
565
- itemsToDownload.addAll(Manifest.findMissingItems(latestManifest, currentManifest));
566
- }
567
- // Copy the files
568
- List<String> hrefsToCopy = new ArrayList<>();
569
- for (ManifestItem item : itemsToCopy) {
570
- hrefsToCopy.add(item.getHref());
571
774
  }
572
- copyCurrentBundleFiles(hrefsToCopy, temporaryDirectory);
573
- // Download the files
574
- List<String> hrefsToDownload = new ArrayList<>();
575
- for (ManifestItem item : itemsToDownload) {
576
- 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);
577
860
  }
578
- downloadBundleFiles(downloadUrl, hrefsToDownload, temporaryDirectory);
579
- // Add the bundle
580
- addBundleOfTypeManifest(bundleId, temporaryDirectory);
581
861
  }
582
862
 
583
- private void downloadBundleOfTypeZip(@NonNull String bundleId, @NonNull String downloadUrl, @Nullable String checksum)
584
- throws Exception {
863
+ private void downloadBundleOfTypeZip(
864
+ @NonNull String bundleId,
865
+ @Nullable String checksum,
866
+ @Nullable String signature,
867
+ @NonNull String downloadUrl,
868
+ @NonNull EmptyCallback completionCallback
869
+ ) {
585
870
  File file = buildTemporaryZipFile();
586
871
  // Download the bundle
587
- downloadAndVerifyFile(downloadUrl, file, checksum);
588
- // Add the bundle
589
- addBundleOfTypeZip(bundleId, file);
590
- // Delete the temporary file
591
- 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
+ );
592
903
  }
593
904
 
594
- @Nullable
595
- private GetLatestBundleResponse fetchLatestBundle(@NonNull FetchLatestBundleOptions options) throws Exception {
596
- String channel = options.getChannel() == null ? getChannel() : options.getChannel();
597
- String url = new HttpUrl.Builder()
598
- .scheme("https")
599
- .host(host)
600
- .addPathSegment("v1")
601
- .addPathSegment("apps")
602
- .addPathSegment(config.getAppId())
603
- .addPathSegment("bundles")
604
- .addPathSegment("latest")
605
- .addQueryParameter("appVersionCode", getVersionCode())
606
- .addQueryParameter("appVersionName", getVersionName())
607
- .addQueryParameter("bundleId", getCurrentBundleId())
608
- .addQueryParameter("channelName", channel)
609
- .addQueryParameter("customId", preferences.getCustomId())
610
- .addQueryParameter("deviceId", getDeviceId())
611
- .addQueryParameter("osVersion", String.valueOf(Build.VERSION.SDK_INT))
612
- .addQueryParameter("platform", "0")
613
- .addQueryParameter("pluginVersion", LiveUpdatePlugin.VERSION)
614
- .build()
615
- .toString();
616
- Logger.debug(LiveUpdatePlugin.TAG, "Fetching latest bundle: " + url);
617
- Response response = httpClient.execute(url);
618
- String responseBodyString = response.body().string();
619
- Logger.debug(LiveUpdatePlugin.TAG, "Latest bundle response: " + responseBodyString);
620
- if (response.isSuccessful()) {
621
- JSONObject responseJson = new JSONObject(responseBodyString);
622
- return new GetLatestBundleResponse(responseJson);
623
- } else {
624
- 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);
625
960
  }
626
961
  }
627
962
 
628
- private String[] getBundleIds() {
963
+ private String[] getDownloadedBundleIds() {
629
964
  File bundlesDirectory = buildBundlesDirectory();
630
965
  File[] bundles = bundlesDirectory.listFiles();
631
966
  if (bundles == null) {
@@ -665,6 +1000,15 @@ public class LiveUpdate {
665
1000
  return checksum.toString();
666
1001
  }
667
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
+
668
1012
  @Nullable
669
1013
  private String getChannel() {
670
1014
  String channel = null;
@@ -678,12 +1022,13 @@ public class LiveUpdate {
678
1022
  }
679
1023
 
680
1024
  /**
681
- * @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.
682
1026
  */
1027
+ @Nullable
683
1028
  private String getCurrentBundleId() {
684
1029
  String currentPath = getCurrentCapacitorServerPath();
685
1030
  if (currentPath.equals(defaultWebAssetDir)) {
686
- return defaultWebAssetDir;
1031
+ return null;
687
1032
  }
688
1033
  return new File(currentPath).getName();
689
1034
  }
@@ -697,22 +1042,22 @@ public class LiveUpdate {
697
1042
 
698
1043
  @NonNull
699
1044
  private String getDeviceId() {
700
- String deviceId = preferences.getDeviceIdForApp(config.getAppId());
1045
+ String deviceId = preferences.getDeviceIdForApp(getAppId());
701
1046
  if (deviceId == null) {
702
1047
  deviceId = UUID.randomUUID().toString().toLowerCase();
703
- preferences.setDeviceIdForApp(config.getAppId(), deviceId);
1048
+ preferences.setDeviceIdForApp(getAppId(), deviceId);
704
1049
  }
705
1050
  return deviceId;
706
1051
  }
707
1052
 
708
1053
  /**
709
- * @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.
710
1055
  */
711
- @NonNull
1056
+ @Nullable
712
1057
  private String getNextBundleId() {
713
1058
  String nextPath = getNextCapacitorServerPath();
714
1059
  if (nextPath.equals(defaultWebAssetDir)) {
715
- return defaultWebAssetDir;
1060
+ return null;
716
1061
  }
717
1062
  return new File(nextPath).getName();
718
1063
  }
@@ -722,31 +1067,54 @@ public class LiveUpdate {
722
1067
  */
723
1068
  @NonNull
724
1069
  private String getNextCapacitorServerPath() {
725
- return plugin
1070
+ String path = plugin
726
1071
  .getContext()
727
1072
  .getSharedPreferences(WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE)
728
1073
  .getString(WebView.CAP_SERVER_PATH, defaultWebAssetDir);
1074
+ // Empty path means default path
1075
+ if (path.isEmpty()) {
1076
+ path = defaultWebAssetDir;
1077
+ }
1078
+ return path;
1079
+ }
1080
+
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();
729
1087
  }
730
1088
 
731
- private String getVersionCode() throws PackageManager.NameNotFoundException {
732
- return String.valueOf(getPackageInfo().versionCode);
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());
733
1095
  }
734
1096
 
735
1097
  private String getVersionName() throws PackageManager.NameNotFoundException {
736
1098
  return getPackageInfo().versionName;
737
1099
  }
738
1100
 
739
- private boolean hasBundle(@NonNull String bundleId) {
1101
+ private boolean hasBundleById(@NonNull String bundleId) {
740
1102
  File bundleDirectory = buildBundleDirectoryFor(bundleId);
741
1103
  return bundleDirectory.exists();
742
1104
  }
743
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
+
744
1112
  @Nullable
745
1113
  private Manifest loadCurrentManifest() throws Exception {
746
1114
  String currentBundleId = getCurrentBundleId();
747
- if (currentBundleId.equals(defaultWebAssetDir)) {
1115
+ if (currentBundleId == null) {
748
1116
  AssetManager assets = plugin.getContext().getAssets();
749
- Boolean manifestFileExists = Arrays.asList(assets.list(defaultWebAssetDir)).contains(manifestFileName);
1117
+ boolean manifestFileExists = Arrays.asList(assets.list(defaultWebAssetDir)).contains(manifestFileName);
750
1118
  if (manifestFileExists) {
751
1119
  InputStream inputStream = assets.open(defaultWebAssetDir + "/" + manifestFileName);
752
1120
  BufferedSource source = Okio.buffer(Okio.source(inputStream));
@@ -776,25 +1144,53 @@ public class LiveUpdate {
776
1144
  return loadManifest(source);
777
1145
  }
778
1146
 
779
- private boolean isBundleInUse(@NonNull String bundleId) {
780
- String currentBundleId = getCurrentBundleId();
781
- String nextBundleId = getNextBundleId();
782
- return bundleId.equals(currentBundleId) || bundleId.equals(nextBundleId);
1147
+ private void notifyDownloadBundleProgressListeners(@NonNull final DownloadBundleProgressEvent event) {
1148
+ plugin.notifyDownloadBundleProgressListeners(event);
783
1149
  }
784
1150
 
785
- private void rollback() {
786
- if (getCurrentBundleId() == defaultWebAssetDir) {
787
- 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.");
788
1156
  return;
789
1157
  }
790
- Logger.debug(LiveUpdatePlugin.TAG, "App is not ready. Rolling back to default bundle.");
791
- setNextCapacitorServerPathToDefaultWebAssetDir();
792
- 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);
793
1177
  }
794
1178
 
795
- private void saveCurrentVersionCode() throws PackageManager.NameNotFoundException {
796
- int currentVersionCode = getPackageInfo().versionCode;
797
- 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
+ }
798
1194
  }
799
1195
 
800
1196
  @Nullable
@@ -822,6 +1218,18 @@ public class LiveUpdate {
822
1218
  return null;
823
1219
  }
824
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
+
825
1233
  private void setCurrentCapacitorServerPath(@NonNull String path) {
826
1234
  if (path.equals(defaultWebAssetDir)) {
827
1235
  this.plugin.getBridge().setServerAssetPath(path);
@@ -829,15 +1237,72 @@ public class LiveUpdate {
829
1237
  this.plugin.getBridge().setServerBasePath(path);
830
1238
  }
831
1239
  this.plugin.getBridge().reload();
1240
+ // Notify listeners
1241
+ notifyReloadedListeners();
832
1242
  }
833
1243
 
834
- private void setCurrentCapacitorServerPathToDefaultWebAssetDir() {
835
- setCurrentCapacitorServerPath(defaultWebAssetDir);
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);
836
1257
  }
837
1258
 
838
- private void setNextBundle(@NonNull String bundleId) {
839
- File bundleDirectory = buildBundleDirectoryFor(bundleId);
840
- setNextCapacitorServerPath(bundleDirectory.getPath());
1259
+ private void notifyNextBundleSetListeners(@Nullable String bundleId) {
1260
+ NextBundleSetEvent event = new NextBundleSetEvent(bundleId);
1261
+ plugin.notifyNextBundleSetListeners(event);
1262
+ }
1263
+
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);
841
1306
  }
842
1307
 
843
1308
  private void setNextCapacitorServerPath(@NonNull String path) {
@@ -845,11 +1310,28 @@ public class LiveUpdate {
845
1310
  this.webViewSettingsEditor.commit();
846
1311
  }
847
1312
 
848
- private void setNextCapacitorServerPathToDefaultWebAssetDir() {
849
- 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);
850
1329
  }
851
1330
 
852
1331
  private void startRollbackTimer() {
1332
+ if (config.getReadyTimeout() <= 0) {
1333
+ return;
1334
+ }
853
1335
  stopRollbackTimer();
854
1336
  rollbackHandler.postDelayed(() -> rollback(), config.getReadyTimeout());
855
1337
  }
@@ -858,6 +1340,15 @@ public class LiveUpdate {
858
1340
  rollbackHandler.removeCallbacksAndMessages(null);
859
1341
  }
860
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
+
861
1352
  private File unzipFile(@NonNull File zipFile) throws IOException {
862
1353
  File destination = buildTemporaryDirectory();
863
1354
  String destinationPath = destination.getPath();
@@ -916,12 +1407,6 @@ public class LiveUpdate {
916
1407
  }
917
1408
  }
918
1409
 
919
- private boolean wasUpdated() throws PackageManager.NameNotFoundException {
920
- int lastVersionCode = preferences.getLastVersionCode();
921
- int currentVersionCode = getPackageInfo().versionCode;
922
- return lastVersionCode != currentVersionCode;
923
- }
924
-
925
1410
  private PackageInfo getPackageInfo() throws PackageManager.NameNotFoundException {
926
1411
  String packageName = this.plugin.getContext().getPackageName();
927
1412
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {