@capgo/capacitor-updater 8.0.1 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CapgoCapacitorUpdater.podspec +7 -5
  2. package/Package.swift +9 -7
  3. package/README.md +984 -215
  4. package/android/build.gradle +24 -12
  5. package/android/proguard-rules.pro +22 -5
  6. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +110 -22
  7. package/android/src/main/java/ee/forgr/capacitor_updater/Callback.java +2 -2
  8. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +1310 -488
  9. package/android/src/main/java/ee/forgr/capacitor_updater/{CapacitorUpdater.java → CapgoUpdater.java} +640 -203
  10. package/android/src/main/java/ee/forgr/capacitor_updater/{CryptoCipherV2.java → CryptoCipher.java} +119 -33
  11. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +0 -3
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +260 -0
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +221 -0
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +497 -133
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +80 -25
  16. package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +338 -0
  17. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +72 -0
  18. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +169 -0
  19. package/dist/docs.json +873 -154
  20. package/dist/esm/definitions.d.ts +881 -114
  21. package/dist/esm/definitions.js.map +1 -1
  22. package/dist/esm/history.d.ts +1 -0
  23. package/dist/esm/history.js +283 -0
  24. package/dist/esm/history.js.map +1 -0
  25. package/dist/esm/index.d.ts +1 -0
  26. package/dist/esm/index.js +1 -0
  27. package/dist/esm/index.js.map +1 -1
  28. package/dist/esm/web.d.ts +12 -1
  29. package/dist/esm/web.js +29 -2
  30. package/dist/esm/web.js.map +1 -1
  31. package/dist/plugin.cjs.js +311 -2
  32. package/dist/plugin.cjs.js.map +1 -1
  33. package/dist/plugin.js +311 -2
  34. package/dist/plugin.js.map +1 -1
  35. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +69 -0
  36. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +55 -0
  37. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleInfo.swift +37 -10
  38. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleStatus.swift +1 -1
  39. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1605 -0
  40. package/ios/{Plugin/CapacitorUpdater.swift → Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift} +523 -230
  41. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +267 -0
  42. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +220 -0
  43. package/ios/Sources/CapacitorUpdaterPlugin/DeviceIdHelper.swift +120 -0
  44. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/InternalUtils.swift +53 -0
  45. package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
  46. package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +274 -0
  47. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +112 -0
  48. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +0 -2
  49. package/package.json +21 -19
  50. package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -975
  51. package/ios/Plugin/CryptoCipherV2.swift +0 -310
  52. /package/{LICENCE → LICENSE} +0 -0
  53. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayCondition.swift +0 -0
  54. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayUntilNext.swift +0 -0
  55. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/Info.plist +0 -0
@@ -6,7 +6,6 @@
6
6
  package ee.forgr.capacitor_updater;
7
7
 
8
8
  import android.content.Context;
9
- import android.util.Log;
10
9
  import androidx.annotation.NonNull;
11
10
  import androidx.work.Data;
12
11
  import androidx.work.Worker;
@@ -16,6 +15,8 @@ import java.io.FileInputStream;
16
15
  import java.net.HttpURLConnection;
17
16
  import java.net.URL;
18
17
  import java.nio.channels.FileChannel;
18
+ import java.nio.file.Files;
19
+ import java.nio.file.StandardCopyOption;
19
20
  import java.security.MessageDigest;
20
21
  import java.util.ArrayList;
21
22
  import java.util.Arrays;
@@ -27,18 +28,33 @@ import java.util.concurrent.Future;
27
28
  import java.util.concurrent.TimeUnit;
28
29
  import java.util.concurrent.atomic.AtomicBoolean;
29
30
  import java.util.concurrent.atomic.AtomicLong;
31
+ import okhttp3.Call;
32
+ import okhttp3.Callback;
33
+ import okhttp3.Interceptor;
34
+ import okhttp3.MediaType;
30
35
  import okhttp3.OkHttpClient;
31
36
  import okhttp3.Protocol;
32
37
  import okhttp3.Request;
38
+ import okhttp3.RequestBody;
33
39
  import okhttp3.Response;
34
40
  import okhttp3.ResponseBody;
41
+ import okio.Buffer;
42
+ import okio.BufferedSink;
43
+ import okio.BufferedSource;
44
+ import okio.Okio;
45
+ import okio.Source;
35
46
  import org.brotli.dec.BrotliInputStream;
36
47
  import org.json.JSONArray;
37
48
  import org.json.JSONObject;
38
49
 
39
50
  public class DownloadService extends Worker {
40
51
 
41
- public static final String TAG = "Capacitor-updater";
52
+ private static Logger logger;
53
+
54
+ public static void setLogger(Logger loggerInstance) {
55
+ logger = loggerInstance;
56
+ }
57
+
42
58
  public static final String URL = "URL";
43
59
  public static final String ID = "id";
44
60
  public static final String PERCENT = "percent";
@@ -50,12 +66,60 @@ public class DownloadService extends Worker {
50
66
  public static final String CHECKSUM = "checksum";
51
67
  public static final String PUBLIC_KEY = "publickey";
52
68
  public static final String IS_MANIFEST = "is_manifest";
69
+ public static final String APP_ID = "app_id";
70
+ public static final String pluginVersion = "plugin_version";
71
+ public static final String STATS_URL = "stats_url";
72
+ public static final String DEVICE_ID = "device_id";
73
+ public static final String CUSTOM_ID = "custom_id";
74
+ public static final String VERSION_BUILD = "version_build";
75
+ public static final String VERSION_CODE = "version_code";
76
+ public static final String VERSION_OS = "version_os";
77
+ public static final String DEFAULT_CHANNEL = "default_channel";
78
+ public static final String IS_PROD = "is_prod";
79
+ public static final String IS_EMULATOR = "is_emulator";
53
80
  private static final String UPDATE_FILE = "update.dat";
54
81
 
55
- private final OkHttpClient client = new OkHttpClient.Builder().protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1)).build();
82
+ // Shared OkHttpClient to prevent resource leaks
83
+ protected static OkHttpClient sharedClient;
84
+ private static String currentAppId = "unknown";
85
+ private static String currentPluginVersion = "unknown";
86
+ private static String currentVersionOs = "unknown";
87
+
88
+ // Initialize shared client with User-Agent interceptor
89
+ static {
90
+ sharedClient = new OkHttpClient.Builder()
91
+ .protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1))
92
+ .addInterceptor((chain) -> {
93
+ Request originalRequest = chain.request();
94
+ String userAgent =
95
+ "CapacitorUpdater/" +
96
+ (currentPluginVersion != null ? currentPluginVersion : "unknown") +
97
+ " (" +
98
+ (currentAppId != null ? currentAppId : "unknown") +
99
+ ") android/" +
100
+ (currentVersionOs != null ? currentVersionOs : "unknown");
101
+ Request requestWithUserAgent = originalRequest.newBuilder().header("User-Agent", userAgent).build();
102
+ return chain.proceed(requestWithUserAgent);
103
+ })
104
+ .build();
105
+ }
106
+
107
+ // Method to update User-Agent values
108
+ public static void updateUserAgent(String appId, String pluginVersion, String versionOs) {
109
+ currentAppId = appId != null ? appId : "unknown";
110
+ currentPluginVersion = pluginVersion != null ? pluginVersion : "unknown";
111
+ currentVersionOs = versionOs != null ? versionOs : "unknown";
112
+ logger.debug(
113
+ "Updated User-Agent: CapacitorUpdater/" + currentPluginVersion + " (" + currentAppId + ") android/" + currentVersionOs
114
+ );
115
+ }
56
116
 
57
117
  public DownloadService(@NonNull Context context, @NonNull WorkerParameters params) {
58
118
  super(context, params);
119
+ // Use shared client - no need to create new instances
120
+
121
+ // Clean up old temporary files on service initialization
122
+ cleanupOldTempFiles(getApplicationContext().getCacheDir());
59
123
  }
60
124
 
61
125
  private void setProgress(int percent) {
@@ -79,6 +143,11 @@ public class DownloadService extends Worker {
79
143
  return Result.success(output);
80
144
  }
81
145
 
146
+ private String getInputString(String key, String fallback) {
147
+ String value = getInputData().getString(key);
148
+ return value != null ? value : fallback;
149
+ }
150
+
82
151
  @NonNull
83
152
  @Override
84
153
  public Result doWork() {
@@ -93,7 +162,7 @@ public class DownloadService extends Worker {
93
162
  String publicKey = getInputData().getString(PUBLIC_KEY);
94
163
  boolean isManifest = getInputData().getBoolean(IS_MANIFEST, false);
95
164
 
96
- Log.d(TAG, "doWork isManifest: " + isManifest);
165
+ logger.debug("doWork isManifest: " + isManifest);
97
166
 
98
167
  if (isManifest) {
99
168
  JSONArray manifest = DataManager.getInstance().getAndClearManifest();
@@ -101,7 +170,7 @@ public class DownloadService extends Worker {
101
170
  handleManifestDownload(id, documentsDir, dest, version, sessionKey, publicKey, manifest.toString());
102
171
  return createSuccessResult(dest, version, sessionKey, checksum, true);
103
172
  } else {
104
- Log.e(TAG, "Manifest is null");
173
+ logger.error("Manifest is null");
105
174
  return createFailureResult("Manifest is null");
106
175
  }
107
176
  } else {
@@ -109,7 +178,6 @@ public class DownloadService extends Worker {
109
178
  return createSuccessResult(dest, version, sessionKey, checksum, false);
110
179
  }
111
180
  } catch (Exception e) {
112
- Log.e(TAG, "Error in doWork", e);
113
181
  return createFailureResult(e.getMessage());
114
182
  }
115
183
  }
@@ -124,6 +192,62 @@ public class DownloadService extends Worker {
124
192
  return percent;
125
193
  }
126
194
 
195
+ private void sendStatsAsync(String action, String version) {
196
+ try {
197
+ String statsUrl = getInputData().getString(STATS_URL);
198
+ if (statsUrl == null || statsUrl.isEmpty()) {
199
+ return;
200
+ }
201
+
202
+ JSONObject json = new JSONObject();
203
+ json.put("platform", "android");
204
+ json.put("app_id", getInputString(APP_ID, "unknown"));
205
+ json.put("plugin_version", getInputString(pluginVersion, "unknown"));
206
+ json.put("version_name", version != null ? version : "");
207
+ json.put("old_version_name", "");
208
+ json.put("action", action);
209
+ json.put("device_id", getInputString(DEVICE_ID, ""));
210
+ json.put("custom_id", getInputString(CUSTOM_ID, ""));
211
+ json.put("version_build", getInputString(VERSION_BUILD, ""));
212
+ json.put("version_code", getInputString(VERSION_CODE, ""));
213
+ json.put("version_os", getInputString(VERSION_OS, currentVersionOs));
214
+ json.put("defaultChannel", getInputString(DEFAULT_CHANNEL, ""));
215
+ json.put("is_prod", getInputData().getBoolean(IS_PROD, true));
216
+ json.put("is_emulator", getInputData().getBoolean(IS_EMULATOR, false));
217
+
218
+ Request request = new Request.Builder()
219
+ .url(statsUrl)
220
+ .post(RequestBody.create(json.toString(), MediaType.get("application/json")))
221
+ .build();
222
+
223
+ sharedClient
224
+ .newCall(request)
225
+ .enqueue(
226
+ new Callback() {
227
+ @Override
228
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
229
+ if (logger != null) {
230
+ logger.error("Failed to send stats: " + e.getMessage());
231
+ }
232
+ }
233
+
234
+ @Override
235
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
236
+ try (ResponseBody body = response.body()) {
237
+ // nothing else to do, just closing body
238
+ } catch (Exception ignored) {} finally {
239
+ response.close();
240
+ }
241
+ }
242
+ }
243
+ );
244
+ } catch (Exception e) {
245
+ if (logger != null) {
246
+ logger.error("sendStatsAsync error: " + e.getMessage());
247
+ }
248
+ }
249
+ }
250
+
127
251
  private void handleManifestDownload(
128
252
  String id,
129
253
  String documentsDir,
@@ -134,7 +258,11 @@ public class DownloadService extends Worker {
134
258
  String manifestString
135
259
  ) {
136
260
  try {
137
- Log.d(TAG, "handleManifestDownload");
261
+ logger.debug("handleManifestDownload");
262
+
263
+ // Send stats for manifest download start
264
+ sendStatsAsync("download_manifest_start", version);
265
+
138
266
  JSONArray manifest = new JSONArray(manifestString);
139
267
  File destFolder = new File(documentsDir, dest);
140
268
  File cacheFolder = new File(getApplicationContext().getCacheDir(), "capgo_downloads");
@@ -163,32 +291,52 @@ public class DownloadService extends Worker {
163
291
  String fileHash = entry.getString("file_hash");
164
292
  String downloadUrl = entry.getString("download_url");
165
293
 
166
- File targetFile = new File(destFolder, fileName);
167
- File cacheFile = new File(cacheFolder, fileHash + "_" + new File(fileName).getName());
294
+ if (!publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
295
+ try {
296
+ fileHash = CryptoCipher.decryptChecksum(fileHash, publicKey);
297
+ } catch (Exception e) {
298
+ logger.error("Error decrypting checksum for " + fileName + "fileHash: " + fileHash);
299
+ hasError.set(true);
300
+ continue;
301
+ }
302
+ }
303
+
304
+ final String finalFileHash = fileHash;
305
+
306
+ // Check if file is a Brotli file and remove .br extension from target
307
+ boolean isBrotli = fileName.endsWith(".br");
308
+ String targetFileName = isBrotli ? fileName.substring(0, fileName.length() - 3) : fileName;
309
+
310
+ File targetFile = new File(destFolder, targetFileName);
311
+ File cacheFile = new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName());
168
312
  File builtinFile = new File(builtinFolder, fileName);
169
313
 
170
314
  // Ensure parent directories of the target file exist
171
315
  if (!Objects.requireNonNull(targetFile.getParentFile()).exists() && !targetFile.getParentFile().mkdirs()) {
172
- throw new IOException("Failed to create parent directory for: " + targetFile.getAbsolutePath());
316
+ logger.error("Failed to create parent directory for: " + targetFile.getAbsolutePath());
317
+ hasError.set(true);
318
+ continue;
173
319
  }
174
320
 
321
+ final boolean finalIsBrotli = isBrotli;
175
322
  Future<?> future = executor.submit(() -> {
176
323
  try {
177
- if (builtinFile.exists() && verifyChecksum(builtinFile, fileHash)) {
324
+ if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
178
325
  copyFile(builtinFile, targetFile);
179
- Log.d(TAG, "using builtin file " + fileName);
180
- } else if (cacheFile.exists() && verifyChecksum(cacheFile, fileHash)) {
326
+ logger.debug("using builtin file " + fileName);
327
+ } else if (cacheFile.exists() && verifyChecksum(cacheFile, finalFileHash)) {
181
328
  copyFile(cacheFile, targetFile);
182
- Log.d(TAG, "already cached " + fileName);
329
+ logger.debug("already cached " + fileName);
183
330
  } else {
184
- downloadAndVerify(downloadUrl, targetFile, cacheFile, fileHash, sessionKey, publicKey);
331
+ downloadAndVerify(downloadUrl, targetFile, cacheFile, finalFileHash, sessionKey, publicKey, finalIsBrotli);
185
332
  }
186
333
 
187
334
  long completed = completedFiles.incrementAndGet();
188
335
  int percent = calcTotalPercent(completed, totalFiles);
189
336
  setProgress(percent);
190
337
  } catch (Exception e) {
191
- Log.e(TAG, "Error processing file: " + fileName, e);
338
+ logger.error("Error processing file: " + fileName + " " + e.getMessage());
339
+ sendStatsAsync("download_manifest_file_fail", version + ":" + fileName);
192
340
  hasError.set(true);
193
341
  }
194
342
  });
@@ -200,7 +348,7 @@ public class DownloadService extends Worker {
200
348
  try {
201
349
  future.get();
202
350
  } catch (Exception e) {
203
- Log.e(TAG, "Error waiting for download", e);
351
+ logger.error("Error waiting for download " + e.getMessage());
204
352
  hasError.set(true);
205
353
  }
206
354
  }
@@ -216,10 +364,15 @@ public class DownloadService extends Worker {
216
364
  }
217
365
 
218
366
  if (hasError.get()) {
367
+ logger.error("One or more files failed to download");
219
368
  throw new IOException("One or more files failed to download");
220
369
  }
370
+
371
+ // Send stats for manifest download complete
372
+ sendStatsAsync("download_manifest_complete", version);
221
373
  } catch (Exception e) {
222
- Log.e(TAG, "Error in handleManifestDownload", e);
374
+ logger.error("Error in handleManifestDownload " + e.getMessage());
375
+ throw new RuntimeException(e.getLocalizedMessage());
223
376
  }
224
377
  }
225
378
 
@@ -232,96 +385,163 @@ public class DownloadService extends Worker {
232
385
  String sessionKey,
233
386
  String checksum
234
387
  ) {
388
+ // Send stats for zip download start
389
+ sendStatsAsync("download_zip_start", version);
390
+
235
391
  File target = new File(documentsDir, dest);
236
- File infoFile = new File(documentsDir, UPDATE_FILE); // The file where the download progress (how much byte
237
- // downloaded) is stored
238
- File tempFile = new File(documentsDir, "temp" + ".tmp"); // Temp file, where the downloaded data is stored
392
+ File infoFile = new File(documentsDir, UPDATE_FILE);
393
+ File tempFile = new File(documentsDir, "temp" + ".tmp");
394
+
395
+ // Check available disk space before starting
396
+ long availableSpace = target.getParentFile().getUsableSpace();
397
+ long estimatedSize = 50 * 1024 * 1024; // 50MB default estimate
398
+ if (availableSpace < estimatedSize * 2) {
399
+ throw new RuntimeException("insufficient_disk_space");
400
+ }
401
+
402
+ HttpURLConnection httpConn = null;
403
+ InputStream inputStream = null;
404
+ FileOutputStream outputStream = null;
405
+ BufferedReader reader = null;
406
+ BufferedWriter writer = null;
407
+
239
408
  try {
240
409
  URL u = new URL(url);
241
- HttpURLConnection httpConn = null;
242
- try {
243
- httpConn = (HttpURLConnection) u.openConnection();
410
+ httpConn = (HttpURLConnection) u.openConnection();
244
411
 
245
- // Reading progress file (if exist)
246
- long downloadedBytes = 0;
412
+ // Set reasonable timeouts
413
+ httpConn.setConnectTimeout(30000); // 30 seconds
414
+ httpConn.setReadTimeout(60000); // 60 seconds
247
415
 
248
- if (infoFile.exists() && tempFile.exists()) {
249
- try (BufferedReader reader = new BufferedReader(new FileReader(infoFile))) {
250
- String updateVersion = reader.readLine();
251
- if (!updateVersion.equals(version)) {
252
- clearDownloadData(documentsDir);
253
- } else {
254
- downloadedBytes = tempFile.length();
255
- }
416
+ // Reading progress file (if exist)
417
+ long downloadedBytes = 0;
418
+
419
+ if (infoFile.exists() && tempFile.exists()) {
420
+ try {
421
+ reader = new BufferedReader(new FileReader(infoFile));
422
+ String updateVersion = reader.readLine();
423
+ if (updateVersion != null && !updateVersion.equals(version)) {
424
+ clearDownloadData(documentsDir);
425
+ } else {
426
+ downloadedBytes = tempFile.length();
427
+ }
428
+ } finally {
429
+ if (reader != null) {
430
+ try {
431
+ reader.close();
432
+ } catch (Exception ignored) {}
256
433
  }
257
- } else {
258
- clearDownloadData(documentsDir);
259
434
  }
435
+ } else {
436
+ clearDownloadData(documentsDir);
437
+ }
438
+
439
+ if (downloadedBytes > 0) {
440
+ httpConn.setRequestProperty("Range", "bytes=" + downloadedBytes + "-");
441
+ }
442
+
443
+ int responseCode = httpConn.getResponseCode();
260
444
 
261
- if (downloadedBytes > 0) {
262
- httpConn.setRequestProperty("Range", "bytes=" + downloadedBytes + "-");
445
+ if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_PARTIAL) {
446
+ long contentLength = httpConn.getContentLength() + downloadedBytes;
447
+
448
+ // Check if we have enough space for the actual file
449
+ if (contentLength > 0 && availableSpace < contentLength * 2) {
450
+ throw new RuntimeException("insufficient_disk_space");
263
451
  }
264
452
 
265
- int responseCode = httpConn.getResponseCode();
453
+ try {
454
+ inputStream = httpConn.getInputStream();
455
+ outputStream = new FileOutputStream(tempFile, downloadedBytes > 0);
456
+
457
+ if (downloadedBytes == 0) {
458
+ writer = new BufferedWriter(new FileWriter(infoFile));
459
+ writer.write(String.valueOf(version));
460
+ writer.close();
461
+ writer = null;
462
+ }
266
463
 
267
- if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_PARTIAL) {
268
- long contentLength = httpConn.getContentLength() + downloadedBytes;
464
+ byte[] buffer = new byte[8192]; // Larger buffer for better performance
465
+ int lastNotifiedPercent = 0;
466
+ int bytesRead;
269
467
 
270
- try (
271
- InputStream inputStream = httpConn.getInputStream();
272
- FileOutputStream outputStream = new FileOutputStream(tempFile, downloadedBytes > 0)
273
- ) {
274
- if (downloadedBytes == 0) {
275
- try (BufferedWriter writer = new BufferedWriter(new FileWriter(infoFile))) {
276
- writer.write(String.valueOf(version));
277
- }
278
- }
279
- // Updating the info file
280
- try (BufferedWriter writer = new BufferedWriter(new FileWriter(infoFile))) {
281
- writer.write(String.valueOf(version));
468
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
469
+ outputStream.write(buffer, 0, bytesRead);
470
+ downloadedBytes += bytesRead;
471
+
472
+ // Flush every 1MB to ensure progress is saved
473
+ if (downloadedBytes % (1024 * 1024) == 0) {
474
+ outputStream.flush();
282
475
  }
283
476
 
284
- int bytesRead = -1;
285
- byte[] buffer = new byte[4096];
286
- int lastNotifiedPercent = 0;
287
- while ((bytesRead = inputStream.read(buffer)) != -1) {
288
- outputStream.write(buffer, 0, bytesRead);
289
- downloadedBytes += bytesRead;
290
- // Saving progress (flushing every 100 Ko)
291
- if (downloadedBytes % 102400 == 0) {
292
- outputStream.flush();
293
- }
294
- // Computing percentage
295
- int percent = calcTotalPercent(downloadedBytes, contentLength);
296
- while (lastNotifiedPercent + 10 <= percent) {
297
- lastNotifiedPercent += 10;
298
- // Artificial delay using CPU-bound calculation to take ~5 seconds
299
- double result = 0;
300
- setProgress(lastNotifiedPercent);
301
- }
477
+ // Computing percentage
478
+ int percent = calcTotalPercent(downloadedBytes, contentLength);
479
+ if (percent >= lastNotifiedPercent + 10) {
480
+ lastNotifiedPercent = (percent / 10) * 10;
481
+ setProgress(lastNotifiedPercent);
302
482
  }
483
+ }
484
+
485
+ // Final flush
486
+ outputStream.flush();
487
+ outputStream.close();
488
+ outputStream = null;
303
489
 
304
- outputStream.close();
305
- inputStream.close();
490
+ inputStream.close();
491
+ inputStream = null;
306
492
 
307
- // Rename the temp file with the final name (dest)
308
- tempFile.renameTo(new File(documentsDir, dest));
309
- infoFile.delete();
493
+ // Rename the temp file with the final name (dest)
494
+ if (!tempFile.renameTo(new File(documentsDir, dest))) {
495
+ throw new RuntimeException("Failed to rename temp file to final destination");
310
496
  }
311
- } else {
312
497
  infoFile.delete();
498
+
499
+ // Send stats for zip download complete
500
+ sendStatsAsync("download_zip_complete", version);
501
+ } catch (OutOfMemoryError e) {
502
+ logger.error("Out of memory during download: " + e.getMessage());
503
+ // Try to free some memory
504
+ System.gc();
505
+ throw new RuntimeException("low_mem_fail");
506
+ } finally {
507
+ // Ensure all resources are closed
508
+ if (outputStream != null) {
509
+ try {
510
+ outputStream.close();
511
+ } catch (Exception ignored) {}
512
+ }
513
+ if (inputStream != null) {
514
+ try {
515
+ inputStream.close();
516
+ } catch (Exception ignored) {}
517
+ }
518
+ if (writer != null) {
519
+ try {
520
+ writer.close();
521
+ } catch (Exception ignored) {}
522
+ }
313
523
  }
314
- } finally {
315
- if (httpConn != null) {
316
- httpConn.disconnect();
317
- }
524
+ } else {
525
+ infoFile.delete();
526
+ throw new RuntimeException("HTTP error: " + responseCode);
318
527
  }
319
528
  } catch (OutOfMemoryError e) {
320
- e.printStackTrace();
529
+ logger.error("Critical memory error: " + e.getMessage());
530
+ System.gc(); // Suggest garbage collection
321
531
  throw new RuntimeException("low_mem_fail");
532
+ } catch (SecurityException e) {
533
+ logger.error("Security error during download: " + e.getMessage());
534
+ throw new RuntimeException("security_error: " + e.getMessage());
322
535
  } catch (Exception e) {
323
- e.printStackTrace();
324
- throw new RuntimeException(e.getLocalizedMessage());
536
+ logger.error("Download error: " + e.getMessage());
537
+ throw new RuntimeException(e.getMessage());
538
+ } finally {
539
+ // Ensure connection is closed
540
+ if (httpConn != null) {
541
+ try {
542
+ httpConn.disconnect();
543
+ } catch (Exception ignored) {}
544
+ }
325
545
  }
326
546
  }
327
547
 
@@ -334,7 +554,8 @@ public class DownloadService extends Worker {
334
554
  infoFile.createNewFile();
335
555
  tempFile.createNewFile();
336
556
  } catch (IOException e) {
337
- e.printStackTrace();
557
+ logger.error("Error in clearDownloadData " + e.getMessage());
558
+ // not a fatal error, so we don't throw an exception
338
559
  }
339
560
  }
340
561
 
@@ -357,67 +578,102 @@ public class DownloadService extends Worker {
357
578
  File cacheFile,
358
579
  String expectedHash,
359
580
  String sessionKey,
360
- String publicKey
581
+ String publicKey,
582
+ boolean isBrotli
361
583
  ) throws Exception {
362
- Log.d(TAG, "downloadAndVerify " + downloadUrl);
584
+ logger.debug("downloadAndVerify " + downloadUrl);
363
585
 
364
586
  Request request = new Request.Builder().url(downloadUrl).build();
365
587
 
588
+ // targetFile is already the final destination without .br extension
589
+ File finalTargetFile = targetFile;
590
+
366
591
  // Create a temporary file for the compressed data
367
- File compressedFile = new File(getApplicationContext().getCacheDir(), "temp_" + targetFile.getName() + ".br");
592
+ File compressedFile = new File(getApplicationContext().getCacheDir(), "temp_" + targetFile.getName() + ".tmp");
368
593
 
369
- try (Response response = client.newCall(request).execute()) {
370
- if (!response.isSuccessful()) {
371
- throw new IOException("Unexpected response code: " + response.code());
372
- }
594
+ try {
595
+ try (Response response = sharedClient.newCall(request).execute()) {
596
+ if (!response.isSuccessful()) {
597
+ sendStatsAsync("download_manifest_file_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
598
+ throw new IOException("Unexpected response code: " + response.code());
599
+ }
373
600
 
374
- // Download compressed file
375
- try (ResponseBody responseBody = response.body(); FileOutputStream compressedFos = new FileOutputStream(compressedFile)) {
601
+ // Download compressed file atomically
602
+ ResponseBody responseBody = response.body();
376
603
  if (responseBody == null) {
377
604
  throw new IOException("Response body is null");
378
605
  }
379
606
 
380
- byte[] buffer = new byte[8192];
381
- int bytesRead;
382
- try (InputStream inputStream = responseBody.byteStream()) {
383
- while ((bytesRead = inputStream.read(buffer)) != -1) {
384
- compressedFos.write(buffer, 0, bytesRead);
385
- }
607
+ // Use OkIO for atomic write
608
+ writeFileAtomic(compressedFile, responseBody.byteStream(), null);
609
+
610
+ if (!publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
611
+ logger.debug("Decrypting file " + targetFile.getName());
612
+ CryptoCipher.decryptFile(compressedFile, publicKey, sessionKey);
386
613
  }
387
- }
388
614
 
389
- String decryptedExpectedHash = expectedHash;
615
+ // Only decompress if file has .br extension
616
+ if (isBrotli) {
617
+ // Use new decompression method with atomic write
618
+ try (FileInputStream fis = new FileInputStream(compressedFile)) {
619
+ byte[] compressedData = new byte[(int) compressedFile.length()];
620
+ fis.read(compressedData);
621
+ byte[] decompressedData;
622
+ try {
623
+ decompressedData = decompressBrotli(compressedData, targetFile.getName());
624
+ } catch (IOException e) {
625
+ sendStatsAsync(
626
+ "download_manifest_brotli_fail",
627
+ getInputData().getString(VERSION) + ":" + finalTargetFile.getName()
628
+ );
629
+ throw e;
630
+ }
390
631
 
391
- if (!publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
392
- Log.d(CapacitorUpdater.TAG + " DLSrv", "Decrypting file " + targetFile.getName());
393
- CryptoCipherV2.decryptFile(compressedFile, publicKey, sessionKey);
394
- decryptedExpectedHash = CryptoCipherV2.decryptChecksum(decryptedExpectedHash, publicKey);
395
- }
632
+ // Write decompressed data atomically
633
+ try (java.io.ByteArrayInputStream bais = new java.io.ByteArrayInputStream(decompressedData)) {
634
+ writeFileAtomic(finalTargetFile, bais, null);
635
+ }
636
+ }
637
+ } else {
638
+ // Just copy the file without decompression using atomic operation
639
+ try (FileInputStream fis = new FileInputStream(compressedFile)) {
640
+ writeFileAtomic(finalTargetFile, fis, null);
641
+ }
642
+ }
396
643
 
397
- // Decompress the file
398
- try (
399
- FileInputStream fis = new FileInputStream(compressedFile);
400
- BrotliInputStream brotliInputStream = new BrotliInputStream(fis);
401
- FileOutputStream fos = new FileOutputStream(targetFile)
402
- ) {
403
- byte[] buffer = new byte[8192];
404
- int len;
405
- while ((len = brotliInputStream.read(buffer)) != -1) {
406
- fos.write(buffer, 0, len);
644
+ // Delete the compressed file
645
+ compressedFile.delete();
646
+ String calculatedHash = CryptoCipher.calcChecksum(finalTargetFile);
647
+ CryptoCipher.logChecksumInfo("Calculated checksum", calculatedHash);
648
+ CryptoCipher.logChecksumInfo("Expected checksum", expectedHash);
649
+
650
+ // Verify checksum
651
+ if (calculatedHash.equals(expectedHash)) {
652
+ // Only cache if checksum is correct - use atomic copy
653
+ try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
654
+ writeFileAtomic(cacheFile, fis, expectedHash);
655
+ }
656
+ } else {
657
+ finalTargetFile.delete();
658
+ sendStatsAsync("download_manifest_checksum_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
659
+ throw new IOException(
660
+ "Checksum verification failed for: " +
661
+ downloadUrl +
662
+ " " +
663
+ targetFile.getName() +
664
+ " expected: " +
665
+ expectedHash +
666
+ " calculated: " +
667
+ calculatedHash
668
+ );
407
669
  }
408
670
  }
409
-
410
- // Delete the compressed file
411
- compressedFile.delete();
412
- String calculatedHash = CryptoCipherV2.calcChecksum(targetFile);
413
-
414
- // Verify checksum
415
- if (calculatedHash.equals(decryptedExpectedHash)) {
416
- // Only cache if checksum is correct
417
- copyFile(targetFile, cacheFile);
418
- } else {
419
- targetFile.delete();
420
- throw new IOException("Checksum verification failed for " + targetFile.getName());
671
+ } catch (Exception e) {
672
+ throw new IOException("Error in downloadAndVerify: " + e.getMessage());
673
+ } finally {
674
+ // Always cleanup the compressed temp file if it still exists
675
+ if (compressedFile.exists()) {
676
+ compressedFile.delete();
421
677
  }
422
678
  }
423
679
  }
@@ -434,14 +690,14 @@ public class DownloadService extends Worker {
434
690
 
435
691
  private String calculateFileHash(File file) throws Exception {
436
692
  MessageDigest digest = MessageDigest.getInstance("SHA-256");
437
- FileInputStream fis = new FileInputStream(file);
438
693
  byte[] byteArray = new byte[1024];
439
694
  int bytesCount = 0;
440
695
 
441
- while ((bytesCount = fis.read(byteArray)) != -1) {
442
- digest.update(byteArray, 0, bytesCount);
696
+ try (FileInputStream fis = new FileInputStream(file)) {
697
+ while ((bytesCount = fis.read(byteArray)) != -1) {
698
+ digest.update(byteArray, 0, bytesCount);
699
+ }
443
700
  }
444
- fis.close();
445
701
 
446
702
  byte[] bytes = digest.digest();
447
703
  StringBuilder sb = new StringBuilder();
@@ -450,4 +706,112 @@ public class DownloadService extends Worker {
450
706
  }
451
707
  return sb.toString();
452
708
  }
709
+
710
+ private byte[] decompressBrotli(byte[] data, String fileName) throws IOException {
711
+ // Validate input
712
+ if (data == null) {
713
+ logger.error("Error: Null data received for " + fileName);
714
+ throw new IOException("Null data received");
715
+ }
716
+
717
+ // Handle empty files
718
+ if (data.length == 0) {
719
+ return new byte[0];
720
+ }
721
+
722
+ // Handle the special EMPTY_BROTLI_STREAM case
723
+ if (data.length == 3 && data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06) {
724
+ return new byte[0];
725
+ }
726
+
727
+ // For small files, check if it's a minimal Brotli wrapper
728
+ if (data.length > 3) {
729
+ try {
730
+ // Handle our minimal wrapper pattern
731
+ if (data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 && data[data.length - 1] == 0x03) {
732
+ return Arrays.copyOfRange(data, 3, data.length - 1);
733
+ }
734
+
735
+ // Handle brotli.compress minimal wrapper (quality 0)
736
+ if (data[0] == 0x0b && data[1] == 0x02 && data[2] == (byte) 0x80 && data[data.length - 1] == 0x03) {
737
+ return Arrays.copyOfRange(data, 3, data.length - 1);
738
+ }
739
+ } catch (ArrayIndexOutOfBoundsException e) {
740
+ logger.error("Error: Malformed data for " + fileName);
741
+ throw new IOException("Malformed data structure");
742
+ }
743
+ }
744
+
745
+ // For all other cases, try standard decompression
746
+ try (
747
+ ByteArrayInputStream bis = new ByteArrayInputStream(data);
748
+ BrotliInputStream brotliInputStream = new BrotliInputStream(bis);
749
+ ByteArrayOutputStream bos = new ByteArrayOutputStream()
750
+ ) {
751
+ byte[] buffer = new byte[8192];
752
+ int len;
753
+ while ((len = brotliInputStream.read(buffer)) != -1) {
754
+ bos.write(buffer, 0, len);
755
+ }
756
+ return bos.toByteArray();
757
+ } catch (IOException e) {
758
+ logger.error("Error: Brotli process failed for " + fileName + ". Status: " + e.getMessage());
759
+ // Add hex dump for debugging
760
+ StringBuilder hexDump = new StringBuilder();
761
+ for (int i = 0; i < Math.min(32, data.length); i++) {
762
+ hexDump.append(String.format("%02x ", data[i]));
763
+ }
764
+ logger.error("Error: Raw data (" + fileName + "): " + hexDump.toString());
765
+ throw e;
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Atomically write data to a file using OkIO
771
+ */
772
+ private void writeFileAtomic(File targetFile, InputStream inputStream, String expectedChecksum) throws IOException {
773
+ File tempFile = new File(targetFile.getParent(), targetFile.getName() + ".tmp");
774
+
775
+ try {
776
+ // Write to temp file first using OkIO
777
+ try (BufferedSink sink = Okio.buffer(Okio.sink(tempFile)); BufferedSource source = Okio.buffer(Okio.source(inputStream))) {
778
+ sink.writeAll(source);
779
+ }
780
+
781
+ // Verify checksum if provided
782
+ if (expectedChecksum != null && !expectedChecksum.isEmpty()) {
783
+ String actualChecksum = CryptoCipher.calcChecksum(tempFile);
784
+ if (!expectedChecksum.equalsIgnoreCase(actualChecksum)) {
785
+ tempFile.delete();
786
+ throw new IOException("Checksum verification failed");
787
+ }
788
+ }
789
+
790
+ // Atomic rename (on same filesystem)
791
+ Files.move(tempFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
792
+ } catch (Exception e) {
793
+ // Clean up temp file on error
794
+ if (tempFile.exists()) {
795
+ tempFile.delete();
796
+ }
797
+ throw new IOException("Failed to write file atomically: " + e.getMessage(), e);
798
+ }
799
+ }
800
+
801
+ /**
802
+ * Clean up old temporary files
803
+ */
804
+ private void cleanupOldTempFiles(File directory) {
805
+ if (directory == null || !directory.exists()) return;
806
+
807
+ File[] tempFiles = directory.listFiles((dir, name) -> name.endsWith(".tmp"));
808
+ if (tempFiles != null) {
809
+ long oneHourAgo = System.currentTimeMillis() - 3600000;
810
+ for (File tempFile : tempFiles) {
811
+ if (tempFile.lastModified() < oneHourAgo) {
812
+ tempFile.delete();
813
+ }
814
+ }
815
+ }
816
+ }
453
817
  }