@capgo/capacitor-updater 8.0.0 → 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 (64) hide show
  1. package/CapgoCapacitorUpdater.podspec +7 -5
  2. package/Package.swift +37 -0
  3. package/README.md +1461 -231
  4. package/android/build.gradle +29 -12
  5. package/android/proguard-rules.pro +45 -0
  6. package/android/src/main/AndroidManifest.xml +0 -1
  7. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +223 -195
  8. package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +23 -23
  9. package/android/src/main/java/ee/forgr/capacitor_updater/Callback.java +13 -0
  10. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +2159 -1234
  11. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1507 -0
  12. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +330 -121
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +28 -0
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +43 -49
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUntilNext.java +4 -4
  16. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +260 -0
  17. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +221 -0
  18. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +808 -117
  19. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +156 -0
  20. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +32 -0
  21. package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +338 -0
  22. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +72 -0
  23. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +169 -0
  24. package/dist/docs.json +2187 -625
  25. package/dist/esm/definitions.d.ts +1286 -249
  26. package/dist/esm/definitions.js.map +1 -1
  27. package/dist/esm/history.d.ts +1 -0
  28. package/dist/esm/history.js +283 -0
  29. package/dist/esm/history.js.map +1 -0
  30. package/dist/esm/index.d.ts +3 -2
  31. package/dist/esm/index.js +5 -4
  32. package/dist/esm/index.js.map +1 -1
  33. package/dist/esm/web.d.ts +36 -41
  34. package/dist/esm/web.js +94 -35
  35. package/dist/esm/web.js.map +1 -1
  36. package/dist/plugin.cjs.js +376 -35
  37. package/dist/plugin.cjs.js.map +1 -1
  38. package/dist/plugin.js +376 -35
  39. package/dist/plugin.js.map +1 -1
  40. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +69 -0
  41. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +55 -0
  42. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleInfo.swift +37 -10
  43. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleStatus.swift +1 -1
  44. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1605 -0
  45. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1526 -0
  46. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +267 -0
  47. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +220 -0
  48. package/ios/Sources/CapacitorUpdaterPlugin/DeviceIdHelper.swift +120 -0
  49. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +311 -0
  50. package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
  51. package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +274 -0
  52. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +112 -0
  53. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +0 -2
  54. package/package.json +41 -35
  55. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdater.java +0 -1130
  56. package/ios/Plugin/CapacitorUpdater.swift +0 -858
  57. package/ios/Plugin/CapacitorUpdaterPlugin.h +0 -10
  58. package/ios/Plugin/CapacitorUpdaterPlugin.m +0 -27
  59. package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -675
  60. package/ios/Plugin/CryptoCipher.swift +0 -240
  61. /package/{LICENCE → LICENSE} +0 -0
  62. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayCondition.swift +0 -0
  63. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayUntilNext.swift +0 -0
  64. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/Info.plist +0 -0
@@ -3,124 +3,815 @@
3
3
  * License, v. 2.0. If a copy of the MPL was not distributed with this
4
4
  * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
5
  */
6
-
7
6
  package ee.forgr.capacitor_updater;
8
7
 
9
- import android.app.IntentService;
10
- import android.content.Intent;
11
- import java.io.DataInputStream;
12
- import java.io.File;
13
- import java.io.FileOutputStream;
14
- import java.io.InputStream;
8
+ import android.content.Context;
9
+ import androidx.annotation.NonNull;
10
+ import androidx.work.Data;
11
+ import androidx.work.Worker;
12
+ import androidx.work.WorkerParameters;
13
+ import java.io.*;
14
+ import java.io.FileInputStream;
15
+ import java.net.HttpURLConnection;
15
16
  import java.net.URL;
16
- import java.net.URLConnection;
17
-
18
- public class DownloadService extends IntentService {
19
-
20
- public static final String URL = "URL";
21
- public static final String ID = "id";
22
- public static final String PERCENT = "percent";
23
- public static final String FILEDEST = "filendest";
24
- public static final String DOCDIR = "docdir";
25
- public static final String ERROR = "error";
26
- public static final String VERSION = "version";
27
- public static final String SESSIONKEY = "sessionkey";
28
- public static final String CHECKSUM = "checksum";
29
- public static final String NOTIFICATION = "service receiver";
30
- public static final String PERCENTDOWNLOAD = "percent receiver";
31
-
32
- public DownloadService() {
33
- super("Background DownloadService");
34
- }
35
-
36
- private int calcTotalPercent(
37
- final int percent,
38
- final int min,
39
- final int max
40
- ) {
41
- return (percent * (max - min)) / 100 + min;
42
- }
43
-
44
- // Will be called asynchronously by OS.
45
- @Override
46
- protected void onHandleIntent(Intent intent) {
47
- String url = intent.getStringExtra(URL);
48
- String id = intent.getStringExtra(ID);
49
- String documentsDir = intent.getStringExtra(DOCDIR);
50
- String dest = intent.getStringExtra(FILEDEST);
51
- String version = intent.getStringExtra(VERSION);
52
- String sessionKey = intent.getStringExtra(SESSIONKEY);
53
- String checksum = intent.getStringExtra(CHECKSUM);
54
-
55
- try {
56
- final URL u = new URL(url);
57
- final URLConnection connection = u.openConnection();
58
- final InputStream is = u.openStream();
59
- final DataInputStream dis = new DataInputStream(is);
60
-
61
- final File target = new File(documentsDir, dest);
62
- target.getParentFile().mkdirs();
63
- target.createNewFile();
64
- final FileOutputStream fos = new FileOutputStream(target);
65
-
66
- final long totalLength = connection.getContentLength();
67
- final int bufferSize = 1024;
68
- final byte[] buffer = new byte[bufferSize];
69
- int length;
70
-
71
- int bytesRead = bufferSize;
72
- int percent = 0;
73
- this.notifyDownload(id, 10);
74
- while ((length = dis.read(buffer)) > 0) {
75
- fos.write(buffer, 0, length);
76
- final int newPercent = (int) ((bytesRead * 100) / totalLength);
77
- if (totalLength > 1 && newPercent != percent) {
78
- percent = newPercent;
79
- this.notifyDownload(id, this.calcTotalPercent(percent, 10, 70));
80
- }
81
- bytesRead += length;
82
- }
83
- publishResults(dest, id, version, checksum, sessionKey, "");
84
- } catch (Exception e) {
85
- e.printStackTrace();
86
- publishResults(
87
- "",
88
- id,
89
- version,
90
- checksum,
91
- sessionKey,
92
- e.getLocalizedMessage()
93
- );
94
- }
95
- }
96
-
97
- private void notifyDownload(String id, int percent) {
98
- Intent intent = new Intent(PERCENTDOWNLOAD);
99
- intent.putExtra(ID, id);
100
- intent.putExtra(PERCENT, percent);
101
- sendBroadcast(intent);
102
- }
103
-
104
- private void publishResults(
105
- String dest,
106
- String id,
107
- String version,
108
- String checksum,
109
- String sessionKey,
110
- String error
111
- ) {
112
- Intent intent = new Intent(NOTIFICATION);
113
- if (dest != null && !dest.isEmpty()) {
114
- intent.putExtra(FILEDEST, dest);
115
- }
116
- if (error != null && !error.isEmpty()) {
117
- intent.putExtra(ERROR, error);
118
- }
119
- intent.putExtra(ID, id);
120
- intent.putExtra(VERSION, version);
121
- intent.putExtra(SESSIONKEY, sessionKey);
122
- intent.putExtra(CHECKSUM, checksum);
123
- intent.putExtra(ERROR, error);
124
- sendBroadcast(intent);
125
- }
17
+ import java.nio.channels.FileChannel;
18
+ import java.nio.file.Files;
19
+ import java.nio.file.StandardCopyOption;
20
+ import java.security.MessageDigest;
21
+ import java.util.ArrayList;
22
+ import java.util.Arrays;
23
+ import java.util.List;
24
+ import java.util.Objects;
25
+ import java.util.concurrent.ExecutorService;
26
+ import java.util.concurrent.Executors;
27
+ import java.util.concurrent.Future;
28
+ import java.util.concurrent.TimeUnit;
29
+ import java.util.concurrent.atomic.AtomicBoolean;
30
+ import java.util.concurrent.atomic.AtomicLong;
31
+ import okhttp3.Call;
32
+ import okhttp3.Callback;
33
+ import okhttp3.Interceptor;
34
+ import okhttp3.MediaType;
35
+ import okhttp3.OkHttpClient;
36
+ import okhttp3.Protocol;
37
+ import okhttp3.Request;
38
+ import okhttp3.RequestBody;
39
+ import okhttp3.Response;
40
+ import okhttp3.ResponseBody;
41
+ import okio.Buffer;
42
+ import okio.BufferedSink;
43
+ import okio.BufferedSource;
44
+ import okio.Okio;
45
+ import okio.Source;
46
+ import org.brotli.dec.BrotliInputStream;
47
+ import org.json.JSONArray;
48
+ import org.json.JSONObject;
49
+
50
+ public class DownloadService extends Worker {
51
+
52
+ private static Logger logger;
53
+
54
+ public static void setLogger(Logger loggerInstance) {
55
+ logger = loggerInstance;
56
+ }
57
+
58
+ public static final String URL = "URL";
59
+ public static final String ID = "id";
60
+ public static final String PERCENT = "percent";
61
+ public static final String FILEDEST = "filendest";
62
+ public static final String DOCDIR = "docdir";
63
+ public static final String ERROR = "error";
64
+ public static final String VERSION = "version";
65
+ public static final String SESSIONKEY = "sessionkey";
66
+ public static final String CHECKSUM = "checksum";
67
+ public static final String PUBLIC_KEY = "publickey";
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";
80
+ private static final String UPDATE_FILE = "update.dat";
81
+
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
+ }
116
+
117
+ public DownloadService(@NonNull Context context, @NonNull WorkerParameters params) {
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());
123
+ }
124
+
125
+ private void setProgress(int percent) {
126
+ Data progress = new Data.Builder().putInt(PERCENT, percent).build();
127
+ setProgressAsync(progress);
128
+ }
129
+
130
+ private Result createFailureResult(String error) {
131
+ Data output = new Data.Builder().putString(ERROR, error).build();
132
+ return Result.failure(output);
133
+ }
134
+
135
+ private Result createSuccessResult(String dest, String version, String sessionKey, String checksum, boolean isManifest) {
136
+ Data output = new Data.Builder()
137
+ .putString(FILEDEST, dest)
138
+ .putString(VERSION, version)
139
+ .putString(SESSIONKEY, sessionKey)
140
+ .putString(CHECKSUM, checksum)
141
+ .putBoolean(IS_MANIFEST, isManifest)
142
+ .build();
143
+ return Result.success(output);
144
+ }
145
+
146
+ private String getInputString(String key, String fallback) {
147
+ String value = getInputData().getString(key);
148
+ return value != null ? value : fallback;
149
+ }
150
+
151
+ @NonNull
152
+ @Override
153
+ public Result doWork() {
154
+ try {
155
+ String url = getInputData().getString(URL);
156
+ String id = getInputData().getString(ID);
157
+ String documentsDir = getInputData().getString(DOCDIR);
158
+ String dest = getInputData().getString(FILEDEST);
159
+ String version = getInputData().getString(VERSION);
160
+ String sessionKey = getInputData().getString(SESSIONKEY);
161
+ String checksum = getInputData().getString(CHECKSUM);
162
+ String publicKey = getInputData().getString(PUBLIC_KEY);
163
+ boolean isManifest = getInputData().getBoolean(IS_MANIFEST, false);
164
+
165
+ logger.debug("doWork isManifest: " + isManifest);
166
+
167
+ if (isManifest) {
168
+ JSONArray manifest = DataManager.getInstance().getAndClearManifest();
169
+ if (manifest != null) {
170
+ handleManifestDownload(id, documentsDir, dest, version, sessionKey, publicKey, manifest.toString());
171
+ return createSuccessResult(dest, version, sessionKey, checksum, true);
172
+ } else {
173
+ logger.error("Manifest is null");
174
+ return createFailureResult("Manifest is null");
175
+ }
176
+ } else {
177
+ handleSingleFileDownload(url, id, documentsDir, dest, version, sessionKey, checksum);
178
+ return createSuccessResult(dest, version, sessionKey, checksum, false);
179
+ }
180
+ } catch (Exception e) {
181
+ return createFailureResult(e.getMessage());
182
+ }
183
+ }
184
+
185
+ private int calcTotalPercent(long downloadedBytes, long contentLength) {
186
+ if (contentLength <= 0) {
187
+ return 0;
188
+ }
189
+ int percent = (int) (((double) downloadedBytes / contentLength) * 100);
190
+ percent = Math.max(10, percent);
191
+ percent = Math.min(70, percent);
192
+ return percent;
193
+ }
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
+
251
+ private void handleManifestDownload(
252
+ String id,
253
+ String documentsDir,
254
+ String dest,
255
+ String version,
256
+ String sessionKey,
257
+ String publicKey,
258
+ String manifestString
259
+ ) {
260
+ try {
261
+ logger.debug("handleManifestDownload");
262
+
263
+ // Send stats for manifest download start
264
+ sendStatsAsync("download_manifest_start", version);
265
+
266
+ JSONArray manifest = new JSONArray(manifestString);
267
+ File destFolder = new File(documentsDir, dest);
268
+ File cacheFolder = new File(getApplicationContext().getCacheDir(), "capgo_downloads");
269
+ File builtinFolder = new File(getApplicationContext().getFilesDir(), "public");
270
+
271
+ // Ensure directories are created
272
+ if (!destFolder.exists() && !destFolder.mkdirs()) {
273
+ throw new IOException("Failed to create destination directory: " + destFolder.getAbsolutePath());
274
+ }
275
+ if (!cacheFolder.exists() && !cacheFolder.mkdirs()) {
276
+ throw new IOException("Failed to create cache directory: " + cacheFolder.getAbsolutePath());
277
+ }
278
+
279
+ int totalFiles = manifest.length();
280
+ final AtomicLong completedFiles = new AtomicLong(0);
281
+ final AtomicBoolean hasError = new AtomicBoolean(false);
282
+
283
+ // Use more threads for I/O-bound operations
284
+ int threadCount = Math.min(64, Math.max(32, totalFiles));
285
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount);
286
+ List<Future<?>> futures = new ArrayList<>();
287
+
288
+ for (int i = 0; i < totalFiles; i++) {
289
+ JSONObject entry = manifest.getJSONObject(i);
290
+ String fileName = entry.getString("file_name");
291
+ String fileHash = entry.getString("file_hash");
292
+ String downloadUrl = entry.getString("download_url");
293
+
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());
312
+ File builtinFile = new File(builtinFolder, fileName);
313
+
314
+ // Ensure parent directories of the target file exist
315
+ if (!Objects.requireNonNull(targetFile.getParentFile()).exists() && !targetFile.getParentFile().mkdirs()) {
316
+ logger.error("Failed to create parent directory for: " + targetFile.getAbsolutePath());
317
+ hasError.set(true);
318
+ continue;
319
+ }
320
+
321
+ final boolean finalIsBrotli = isBrotli;
322
+ Future<?> future = executor.submit(() -> {
323
+ try {
324
+ if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
325
+ copyFile(builtinFile, targetFile);
326
+ logger.debug("using builtin file " + fileName);
327
+ } else if (cacheFile.exists() && verifyChecksum(cacheFile, finalFileHash)) {
328
+ copyFile(cacheFile, targetFile);
329
+ logger.debug("already cached " + fileName);
330
+ } else {
331
+ downloadAndVerify(downloadUrl, targetFile, cacheFile, finalFileHash, sessionKey, publicKey, finalIsBrotli);
332
+ }
333
+
334
+ long completed = completedFiles.incrementAndGet();
335
+ int percent = calcTotalPercent(completed, totalFiles);
336
+ setProgress(percent);
337
+ } catch (Exception e) {
338
+ logger.error("Error processing file: " + fileName + " " + e.getMessage());
339
+ sendStatsAsync("download_manifest_file_fail", version + ":" + fileName);
340
+ hasError.set(true);
341
+ }
342
+ });
343
+ futures.add(future);
344
+ }
345
+
346
+ // Wait for all downloads to complete
347
+ for (Future<?> future : futures) {
348
+ try {
349
+ future.get();
350
+ } catch (Exception e) {
351
+ logger.error("Error waiting for download " + e.getMessage());
352
+ hasError.set(true);
353
+ }
354
+ }
355
+
356
+ executor.shutdown();
357
+ try {
358
+ if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
359
+ executor.shutdownNow();
360
+ }
361
+ } catch (InterruptedException e) {
362
+ executor.shutdownNow();
363
+ Thread.currentThread().interrupt();
364
+ }
365
+
366
+ if (hasError.get()) {
367
+ logger.error("One or more files failed to download");
368
+ throw new IOException("One or more files failed to download");
369
+ }
370
+
371
+ // Send stats for manifest download complete
372
+ sendStatsAsync("download_manifest_complete", version);
373
+ } catch (Exception e) {
374
+ logger.error("Error in handleManifestDownload " + e.getMessage());
375
+ throw new RuntimeException(e.getLocalizedMessage());
376
+ }
377
+ }
378
+
379
+ private void handleSingleFileDownload(
380
+ String url,
381
+ String id,
382
+ String documentsDir,
383
+ String dest,
384
+ String version,
385
+ String sessionKey,
386
+ String checksum
387
+ ) {
388
+ // Send stats for zip download start
389
+ sendStatsAsync("download_zip_start", version);
390
+
391
+ File target = new File(documentsDir, dest);
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
+
408
+ try {
409
+ URL u = new URL(url);
410
+ httpConn = (HttpURLConnection) u.openConnection();
411
+
412
+ // Set reasonable timeouts
413
+ httpConn.setConnectTimeout(30000); // 30 seconds
414
+ httpConn.setReadTimeout(60000); // 60 seconds
415
+
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) {}
433
+ }
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();
444
+
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");
451
+ }
452
+
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
+ }
463
+
464
+ byte[] buffer = new byte[8192]; // Larger buffer for better performance
465
+ int lastNotifiedPercent = 0;
466
+ int bytesRead;
467
+
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();
475
+ }
476
+
477
+ // Computing percentage
478
+ int percent = calcTotalPercent(downloadedBytes, contentLength);
479
+ if (percent >= lastNotifiedPercent + 10) {
480
+ lastNotifiedPercent = (percent / 10) * 10;
481
+ setProgress(lastNotifiedPercent);
482
+ }
483
+ }
484
+
485
+ // Final flush
486
+ outputStream.flush();
487
+ outputStream.close();
488
+ outputStream = null;
489
+
490
+ inputStream.close();
491
+ inputStream = null;
492
+
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");
496
+ }
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
+ }
523
+ }
524
+ } else {
525
+ infoFile.delete();
526
+ throw new RuntimeException("HTTP error: " + responseCode);
527
+ }
528
+ } catch (OutOfMemoryError e) {
529
+ logger.error("Critical memory error: " + e.getMessage());
530
+ System.gc(); // Suggest garbage collection
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());
535
+ } catch (Exception e) {
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
+ }
545
+ }
546
+ }
547
+
548
+ private void clearDownloadData(String docDir) {
549
+ File tempFile = new File(docDir, "temp" + ".tmp");
550
+ File infoFile = new File(docDir, UPDATE_FILE);
551
+ try {
552
+ tempFile.delete();
553
+ infoFile.delete();
554
+ infoFile.createNewFile();
555
+ tempFile.createNewFile();
556
+ } catch (IOException e) {
557
+ logger.error("Error in clearDownloadData " + e.getMessage());
558
+ // not a fatal error, so we don't throw an exception
559
+ }
560
+ }
561
+
562
+ // Helper methods
563
+
564
+ private void copyFile(File source, File dest) throws IOException {
565
+ try (
566
+ FileInputStream inStream = new FileInputStream(source);
567
+ FileOutputStream outStream = new FileOutputStream(dest);
568
+ FileChannel inChannel = inStream.getChannel();
569
+ FileChannel outChannel = outStream.getChannel()
570
+ ) {
571
+ inChannel.transferTo(0, inChannel.size(), outChannel);
572
+ }
573
+ }
574
+
575
+ private void downloadAndVerify(
576
+ String downloadUrl,
577
+ File targetFile,
578
+ File cacheFile,
579
+ String expectedHash,
580
+ String sessionKey,
581
+ String publicKey,
582
+ boolean isBrotli
583
+ ) throws Exception {
584
+ logger.debug("downloadAndVerify " + downloadUrl);
585
+
586
+ Request request = new Request.Builder().url(downloadUrl).build();
587
+
588
+ // targetFile is already the final destination without .br extension
589
+ File finalTargetFile = targetFile;
590
+
591
+ // Create a temporary file for the compressed data
592
+ File compressedFile = new File(getApplicationContext().getCacheDir(), "temp_" + targetFile.getName() + ".tmp");
593
+
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
+ }
600
+
601
+ // Download compressed file atomically
602
+ ResponseBody responseBody = response.body();
603
+ if (responseBody == null) {
604
+ throw new IOException("Response body is null");
605
+ }
606
+
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);
613
+ }
614
+
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
+ }
631
+
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
+ }
643
+
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
+ );
669
+ }
670
+ }
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();
677
+ }
678
+ }
679
+ }
680
+
681
+ private boolean verifyChecksum(File file, String expectedHash) {
682
+ try {
683
+ String actualHash = calculateFileHash(file);
684
+ return actualHash.equals(expectedHash);
685
+ } catch (Exception e) {
686
+ e.printStackTrace();
687
+ return false;
688
+ }
689
+ }
690
+
691
+ private String calculateFileHash(File file) throws Exception {
692
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
693
+ byte[] byteArray = new byte[1024];
694
+ int bytesCount = 0;
695
+
696
+ try (FileInputStream fis = new FileInputStream(file)) {
697
+ while ((bytesCount = fis.read(byteArray)) != -1) {
698
+ digest.update(byteArray, 0, bytesCount);
699
+ }
700
+ }
701
+
702
+ byte[] bytes = digest.digest();
703
+ StringBuilder sb = new StringBuilder();
704
+ for (byte aByte : bytes) {
705
+ sb.append(Integer.toString((aByte & 0xff) + 0x100, 16).substring(1));
706
+ }
707
+ return sb.toString();
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
+ }
126
817
  }