@capawesome/capacitor-live-update 6.8.0-dev.2e2696e1.1773643381 → 6.9.0

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