@capgo/capacitor-updater 4.42.0 → 4.43.5

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 (67) hide show
  1. package/CapgoCapacitorUpdater.podspec +7 -5
  2. package/Package.swift +40 -0
  3. package/README.md +1913 -303
  4. package/android/build.gradle +41 -8
  5. package/android/proguard-rules.pro +45 -0
  6. package/android/src/main/AndroidManifest.xml +1 -3
  7. package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +88 -0
  8. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +223 -195
  9. package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +23 -23
  10. package/android/src/main/java/ee/forgr/capacitor_updater/Callback.java +13 -0
  11. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +2720 -1242
  12. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1854 -0
  13. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +359 -121
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +28 -0
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +44 -49
  16. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUntilNext.java +4 -4
  17. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +296 -0
  18. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +215 -0
  19. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +858 -117
  20. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +156 -0
  21. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +45 -0
  22. package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +360 -0
  23. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +72 -0
  24. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +603 -0
  25. package/dist/docs.json +3022 -765
  26. package/dist/esm/definitions.d.ts +1717 -198
  27. package/dist/esm/definitions.js +103 -1
  28. package/dist/esm/definitions.js.map +1 -1
  29. package/dist/esm/history.d.ts +1 -0
  30. package/dist/esm/history.js +283 -0
  31. package/dist/esm/history.js.map +1 -0
  32. package/dist/esm/index.d.ts +3 -2
  33. package/dist/esm/index.js +5 -4
  34. package/dist/esm/index.js.map +1 -1
  35. package/dist/esm/web.d.ts +43 -42
  36. package/dist/esm/web.js +122 -37
  37. package/dist/esm/web.js.map +1 -1
  38. package/dist/plugin.cjs.js +512 -37
  39. package/dist/plugin.cjs.js.map +1 -1
  40. package/dist/plugin.js +512 -37
  41. package/dist/plugin.js.map +1 -1
  42. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +87 -0
  43. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +55 -0
  44. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +177 -0
  45. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleStatus.swift +12 -12
  46. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +2020 -0
  47. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1959 -0
  48. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +313 -0
  49. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +257 -0
  50. package/ios/Sources/CapacitorUpdaterPlugin/DeviceIdHelper.swift +120 -0
  51. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +392 -0
  52. package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
  53. package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +274 -0
  54. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +441 -0
  55. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +1 -2
  56. package/package.json +49 -41
  57. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdater.java +0 -1131
  58. package/ios/Plugin/BundleInfo.swift +0 -113
  59. package/ios/Plugin/CapacitorUpdater.swift +0 -850
  60. package/ios/Plugin/CapacitorUpdaterPlugin.h +0 -10
  61. package/ios/Plugin/CapacitorUpdaterPlugin.m +0 -27
  62. package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -678
  63. package/ios/Plugin/CryptoCipher.swift +0 -240
  64. /package/{LICENCE → LICENSE} +0 -0
  65. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayCondition.swift +0 -0
  66. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayUntilNext.swift +0 -0
  67. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/Info.plist +0 -0
@@ -3,124 +3,865 @@
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.optString("file_hash", "");
292
+ String downloadUrl = entry.getString("download_url");
293
+
294
+ if (fileHash.isEmpty()) {
295
+ logger.error("Missing file_hash for manifest entry: " + fileName);
296
+ hasError.set(true);
297
+ continue;
298
+ }
299
+
300
+ if (publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
301
+ try {
302
+ fileHash = CryptoCipher.decryptChecksum(fileHash, publicKey);
303
+ } catch (Exception e) {
304
+ logger.error("Error decrypting checksum for " + fileName + "fileHash: " + fileHash);
305
+ hasError.set(true);
306
+ continue;
307
+ }
308
+ }
309
+
310
+ final String finalFileHash = fileHash;
311
+
312
+ // Check if file is a Brotli file and remove .br extension from target
313
+ boolean isBrotli = fileName.endsWith(".br");
314
+ String targetFileName = isBrotli ? fileName.substring(0, fileName.length() - 3) : fileName;
315
+
316
+ File targetFile = new File(destFolder, targetFileName);
317
+ String cacheBaseName = new File(isBrotli ? targetFileName : fileName).getName();
318
+ File cacheFile = new File(cacheFolder, finalFileHash + "_" + cacheBaseName);
319
+ final File legacyCacheFile = isBrotli ? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName()) : null;
320
+ File builtinFile = new File(builtinFolder, fileName);
321
+
322
+ // Ensure parent directories of the target file exist
323
+ if (!Objects.requireNonNull(targetFile.getParentFile()).exists() && !targetFile.getParentFile().mkdirs()) {
324
+ logger.error("Failed to create parent directory for: " + targetFile.getAbsolutePath());
325
+ hasError.set(true);
326
+ continue;
327
+ }
328
+
329
+ final boolean finalIsBrotli = isBrotli;
330
+ Future<?> future = executor.submit(() -> {
331
+ try {
332
+ if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
333
+ copyFile(builtinFile, targetFile);
334
+ logger.debug("using builtin file " + fileName);
335
+ } else if (
336
+ tryCopyFromCache(cacheFile, targetFile, finalFileHash) ||
337
+ (legacyCacheFile != null && tryCopyFromCache(legacyCacheFile, targetFile, finalFileHash))
338
+ ) {
339
+ logger.debug("already cached " + fileName);
340
+ } else {
341
+ downloadAndVerify(downloadUrl, targetFile, cacheFile, finalFileHash, sessionKey, publicKey, finalIsBrotli);
342
+ }
343
+
344
+ long completed = completedFiles.incrementAndGet();
345
+ int percent = calcTotalPercent(completed, totalFiles);
346
+ setProgress(percent);
347
+ } catch (Exception e) {
348
+ logger.error("Error processing file: " + fileName + " " + e.getMessage());
349
+ sendStatsAsync("download_manifest_file_fail", version + ":" + fileName);
350
+ hasError.set(true);
351
+ }
352
+ });
353
+ futures.add(future);
354
+ }
355
+
356
+ // Wait for all downloads to complete
357
+ for (Future<?> future : futures) {
358
+ try {
359
+ future.get();
360
+ } catch (Exception e) {
361
+ logger.error("Error waiting for download " + e.getMessage());
362
+ hasError.set(true);
363
+ }
364
+ }
365
+
366
+ executor.shutdown();
367
+ try {
368
+ if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
369
+ executor.shutdownNow();
370
+ }
371
+ } catch (InterruptedException e) {
372
+ executor.shutdownNow();
373
+ Thread.currentThread().interrupt();
374
+ }
375
+
376
+ if (hasError.get()) {
377
+ logger.error("One or more files failed to download");
378
+ throw new IOException("One or more files failed to download");
379
+ }
380
+
381
+ // Send stats for manifest download complete
382
+ sendStatsAsync("download_manifest_complete", version);
383
+ } catch (Exception e) {
384
+ logger.error("Error in handleManifestDownload " + e.getMessage());
385
+ throw new RuntimeException(e.getLocalizedMessage());
386
+ }
387
+ }
388
+
389
+ private void handleSingleFileDownload(
390
+ String url,
391
+ String id,
392
+ String documentsDir,
393
+ String dest,
394
+ String version,
395
+ String sessionKey,
396
+ String checksum
397
+ ) {
398
+ // Send stats for zip download start
399
+ sendStatsAsync("download_zip_start", version);
400
+
401
+ File target = new File(documentsDir, dest);
402
+ // Use bundle ID in temp file names to prevent collisions when multiple downloads run concurrently
403
+ File infoFile = new File(documentsDir, "update_" + id + ".dat");
404
+ File tempFile = new File(documentsDir, "temp_" + id + ".tmp");
405
+
406
+ // Check available disk space before starting
407
+ long availableSpace = target.getParentFile().getUsableSpace();
408
+ long estimatedSize = 50 * 1024 * 1024; // 50MB default estimate
409
+ if (availableSpace < estimatedSize * 2) {
410
+ throw new RuntimeException("insufficient_disk_space");
411
+ }
412
+
413
+ HttpURLConnection httpConn = null;
414
+ InputStream inputStream = null;
415
+ FileOutputStream outputStream = null;
416
+ BufferedReader reader = null;
417
+ BufferedWriter writer = null;
418
+
419
+ try {
420
+ URL u = new URL(url);
421
+ httpConn = (HttpURLConnection) u.openConnection();
422
+
423
+ // Set reasonable timeouts
424
+ httpConn.setConnectTimeout(30000); // 30 seconds
425
+ httpConn.setReadTimeout(60000); // 60 seconds
426
+
427
+ // Reading progress file (if exist)
428
+ long downloadedBytes = 0;
429
+
430
+ if (infoFile.exists() && tempFile.exists()) {
431
+ try {
432
+ reader = new BufferedReader(new FileReader(infoFile));
433
+ String updateVersion = reader.readLine();
434
+ if (updateVersion != null && !updateVersion.equals(version)) {
435
+ clearDownloadData(documentsDir, id);
436
+ } else {
437
+ downloadedBytes = tempFile.length();
438
+ }
439
+ } finally {
440
+ if (reader != null) {
441
+ try {
442
+ reader.close();
443
+ } catch (Exception ignored) {}
444
+ }
445
+ }
446
+ } else {
447
+ clearDownloadData(documentsDir, id);
448
+ }
449
+
450
+ if (downloadedBytes > 0) {
451
+ httpConn.setRequestProperty("Range", "bytes=" + downloadedBytes + "-");
452
+ }
453
+
454
+ int responseCode = httpConn.getResponseCode();
455
+
456
+ if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_PARTIAL) {
457
+ long contentLength = httpConn.getContentLength() + downloadedBytes;
458
+
459
+ // Check if we have enough space for the actual file
460
+ if (contentLength > 0 && availableSpace < contentLength * 2) {
461
+ throw new RuntimeException("insufficient_disk_space");
462
+ }
463
+
464
+ try {
465
+ inputStream = httpConn.getInputStream();
466
+ outputStream = new FileOutputStream(tempFile, downloadedBytes > 0);
467
+
468
+ if (downloadedBytes == 0) {
469
+ writer = new BufferedWriter(new FileWriter(infoFile));
470
+ writer.write(String.valueOf(version));
471
+ writer.close();
472
+ writer = null;
473
+ }
474
+
475
+ byte[] buffer = new byte[8192]; // Larger buffer for better performance
476
+ int lastNotifiedPercent = 0;
477
+ int bytesRead;
478
+
479
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
480
+ outputStream.write(buffer, 0, bytesRead);
481
+ downloadedBytes += bytesRead;
482
+
483
+ // Flush every 1MB to ensure progress is saved
484
+ if (downloadedBytes % (1024 * 1024) == 0) {
485
+ outputStream.flush();
486
+ }
487
+
488
+ // Computing percentage
489
+ int percent = calcTotalPercent(downloadedBytes, contentLength);
490
+ if (percent >= lastNotifiedPercent + 10) {
491
+ lastNotifiedPercent = (percent / 10) * 10;
492
+ setProgress(lastNotifiedPercent);
493
+ }
494
+ }
495
+
496
+ // Final flush
497
+ outputStream.flush();
498
+ outputStream.close();
499
+ outputStream = null;
500
+
501
+ inputStream.close();
502
+ inputStream = null;
503
+
504
+ // Rename the temp file with the final name (dest)
505
+ if (!tempFile.renameTo(new File(documentsDir, dest))) {
506
+ throw new RuntimeException("Failed to rename temp file to final destination");
507
+ }
508
+ infoFile.delete();
509
+
510
+ // Send stats for zip download complete
511
+ sendStatsAsync("download_zip_complete", version);
512
+ } catch (OutOfMemoryError e) {
513
+ logger.error("Out of memory during download: " + e.getMessage());
514
+ // Try to free some memory
515
+ System.gc();
516
+ throw new RuntimeException("low_mem_fail");
517
+ } finally {
518
+ // Ensure all resources are closed
519
+ if (outputStream != null) {
520
+ try {
521
+ outputStream.close();
522
+ } catch (Exception ignored) {}
523
+ }
524
+ if (inputStream != null) {
525
+ try {
526
+ inputStream.close();
527
+ } catch (Exception ignored) {}
528
+ }
529
+ if (writer != null) {
530
+ try {
531
+ writer.close();
532
+ } catch (Exception ignored) {}
533
+ }
534
+ }
535
+ } else {
536
+ infoFile.delete();
537
+ throw new RuntimeException("HTTP error: " + responseCode);
538
+ }
539
+ } catch (OutOfMemoryError e) {
540
+ logger.error("Critical memory error: " + e.getMessage());
541
+ System.gc(); // Suggest garbage collection
542
+ throw new RuntimeException("low_mem_fail");
543
+ } catch (SecurityException e) {
544
+ logger.error("Security error during download: " + e.getMessage());
545
+ throw new RuntimeException("security_error: " + e.getMessage());
546
+ } catch (Exception e) {
547
+ logger.error("Download error: " + e.getMessage());
548
+ throw new RuntimeException(e.getMessage());
549
+ } finally {
550
+ // Ensure connection is closed
551
+ if (httpConn != null) {
552
+ try {
553
+ httpConn.disconnect();
554
+ } catch (Exception ignored) {}
555
+ }
556
+ }
557
+ }
558
+
559
+ private void clearDownloadData(String docDir, String id) {
560
+ File tempFile = new File(docDir, "temp_" + id + ".tmp");
561
+ File infoFile = new File(docDir, "update_" + id + ".dat");
562
+ try {
563
+ tempFile.delete();
564
+ infoFile.delete();
565
+ infoFile.createNewFile();
566
+ tempFile.createNewFile();
567
+ } catch (IOException e) {
568
+ logger.error("Error in clearDownloadData " + e.getMessage());
569
+ // not a fatal error, so we don't throw an exception
570
+ }
571
+ }
572
+
573
+ // Helper methods
574
+
575
+ /**
576
+ * Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed.
577
+ * This handles the race condition where OS can delete cache files between exists() check and copy.
578
+ */
579
+ private boolean tryCopyFromCache(File source, File dest, String expectedHash) {
580
+ // First quick check - if file doesn't exist, don't bother
581
+ if (!source.exists()) {
582
+ return false;
583
+ }
584
+
585
+ // Verify checksum before copy
586
+ if (!verifyChecksum(source, expectedHash)) {
587
+ return false;
588
+ }
589
+
590
+ // Try to copy - if it fails (file deleted by OS between check and copy), return false
591
+ try {
592
+ copyFile(source, dest);
593
+ return true;
594
+ } catch (IOException e) {
595
+ // File was deleted between check and copy, or other IO error - caller should download instead
596
+ logger.debug("Cache copy failed (likely OS eviction): " + e.getMessage());
597
+ return false;
598
+ }
599
+ }
600
+
601
+ private void copyFile(File source, File dest) throws IOException {
602
+ try (
603
+ FileInputStream inStream = new FileInputStream(source);
604
+ FileOutputStream outStream = new FileOutputStream(dest);
605
+ FileChannel inChannel = inStream.getChannel();
606
+ FileChannel outChannel = outStream.getChannel()
607
+ ) {
608
+ inChannel.transferTo(0, inChannel.size(), outChannel);
609
+ }
610
+ }
611
+
612
+ private void downloadAndVerify(
613
+ String downloadUrl,
614
+ File targetFile,
615
+ File cacheFile,
616
+ String expectedHash,
617
+ String sessionKey,
618
+ String publicKey,
619
+ boolean isBrotli
620
+ ) throws Exception {
621
+ logger.debug("downloadAndVerify " + downloadUrl);
622
+
623
+ Request request = new Request.Builder().url(downloadUrl).build();
624
+
625
+ // targetFile is already the final destination without .br extension
626
+ File finalTargetFile = targetFile;
627
+
628
+ // Create a temporary file for the compressed data with a unique name to avoid race conditions
629
+ // between threads processing files with the same basename in different directories
630
+ File compressedFile = new File(
631
+ getApplicationContext().getCacheDir(),
632
+ "temp_" + java.util.UUID.randomUUID().toString() + "_" + targetFile.getName() + ".tmp"
633
+ );
634
+
635
+ try {
636
+ try (Response response = sharedClient.newCall(request).execute()) {
637
+ if (!response.isSuccessful()) {
638
+ sendStatsAsync("download_manifest_file_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
639
+ throw new IOException("Unexpected response code: " + response.code());
640
+ }
641
+
642
+ // Download compressed file atomically
643
+ ResponseBody responseBody = response.body();
644
+ if (responseBody == null) {
645
+ throw new IOException("Response body is null");
646
+ }
647
+
648
+ // Use OkIO for atomic write
649
+ writeFileAtomic(compressedFile, responseBody.byteStream(), null);
650
+
651
+ if (publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
652
+ logger.debug("Decrypting file " + targetFile.getName());
653
+ CryptoCipher.decryptFile(compressedFile, publicKey, sessionKey);
654
+ }
655
+
656
+ // Only decompress if file has .br extension
657
+ if (isBrotli) {
658
+ // Use new decompression method with atomic write
659
+ try (FileInputStream fis = new FileInputStream(compressedFile)) {
660
+ byte[] compressedData = new byte[(int) compressedFile.length()];
661
+ int offset = 0;
662
+ int bytesRead;
663
+ while (
664
+ offset < compressedData.length &&
665
+ (bytesRead = fis.read(compressedData, offset, compressedData.length - offset)) != -1
666
+ ) {
667
+ offset += bytesRead;
668
+ }
669
+ byte[] decompressedData;
670
+ try {
671
+ decompressedData = decompressBrotli(compressedData, targetFile.getName());
672
+ } catch (IOException e) {
673
+ sendStatsAsync(
674
+ "download_manifest_brotli_fail",
675
+ getInputData().getString(VERSION) + ":" + finalTargetFile.getName()
676
+ );
677
+ throw e;
678
+ }
679
+
680
+ // Write decompressed data atomically
681
+ try (java.io.ByteArrayInputStream bais = new java.io.ByteArrayInputStream(decompressedData)) {
682
+ writeFileAtomic(finalTargetFile, bais, null);
683
+ }
684
+ }
685
+ } else {
686
+ // Just copy the file without decompression using atomic operation
687
+ try (FileInputStream fis = new FileInputStream(compressedFile)) {
688
+ writeFileAtomic(finalTargetFile, fis, null);
689
+ }
690
+ }
691
+
692
+ // Delete the compressed file
693
+ compressedFile.delete();
694
+ String calculatedHash = CryptoCipher.calcChecksum(finalTargetFile);
695
+ CryptoCipher.logChecksumInfo("Calculated checksum", calculatedHash);
696
+ CryptoCipher.logChecksumInfo("Expected checksum", expectedHash);
697
+
698
+ // Verify checksum
699
+ if (calculatedHash.equalsIgnoreCase(expectedHash)) {
700
+ // Only cache if checksum is correct - use atomic copy
701
+ try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
702
+ writeFileAtomic(cacheFile, fis, expectedHash);
703
+ }
704
+ } else {
705
+ finalTargetFile.delete();
706
+ sendStatsAsync("download_manifest_checksum_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
707
+ throw new IOException(
708
+ "Checksum verification failed for: " +
709
+ downloadUrl +
710
+ " " +
711
+ targetFile.getName() +
712
+ " expected: " +
713
+ expectedHash +
714
+ " calculated: " +
715
+ calculatedHash
716
+ );
717
+ }
718
+ }
719
+ } catch (Exception e) {
720
+ throw new IOException("Error in downloadAndVerify: " + e.getMessage());
721
+ } finally {
722
+ // Always cleanup the compressed temp file if it still exists
723
+ if (compressedFile.exists()) {
724
+ compressedFile.delete();
725
+ }
726
+ }
727
+ }
728
+
729
+ private boolean verifyChecksum(File file, String expectedHash) {
730
+ try {
731
+ String actualHash = calculateFileHash(file);
732
+ return actualHash.equalsIgnoreCase(expectedHash);
733
+ } catch (Exception e) {
734
+ e.printStackTrace();
735
+ return false;
736
+ }
737
+ }
738
+
739
+ private String calculateFileHash(File file) throws Exception {
740
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
741
+ byte[] byteArray = new byte[1024];
742
+ int bytesCount = 0;
743
+
744
+ try (FileInputStream fis = new FileInputStream(file)) {
745
+ while ((bytesCount = fis.read(byteArray)) != -1) {
746
+ digest.update(byteArray, 0, bytesCount);
747
+ }
748
+ }
749
+
750
+ byte[] bytes = digest.digest();
751
+ StringBuilder sb = new StringBuilder();
752
+ for (byte aByte : bytes) {
753
+ sb.append(Integer.toString((aByte & 0xff) + 0x100, 16).substring(1));
754
+ }
755
+ return sb.toString();
756
+ }
757
+
758
+ private byte[] decompressBrotli(byte[] data, String fileName) throws IOException {
759
+ // Validate input
760
+ if (data == null) {
761
+ logger.error("Error: Null data received for " + fileName);
762
+ throw new IOException("Null data received");
763
+ }
764
+
765
+ // Handle empty files
766
+ if (data.length == 0) {
767
+ return new byte[0];
768
+ }
769
+
770
+ // Handle the special EMPTY_BROTLI_STREAM case
771
+ if (data.length == 3 && data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06) {
772
+ return new byte[0];
773
+ }
774
+
775
+ // For small files, check if it's a minimal Brotli wrapper
776
+ if (data.length > 3) {
777
+ try {
778
+ // Handle our minimal wrapper pattern
779
+ if (data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 && data[data.length - 1] == 0x03) {
780
+ return Arrays.copyOfRange(data, 3, data.length - 1);
781
+ }
782
+
783
+ // Handle brotli.compress minimal wrapper (quality 0)
784
+ if (data[0] == 0x0b && data[1] == 0x02 && data[2] == (byte) 0x80 && data[data.length - 1] == 0x03) {
785
+ return Arrays.copyOfRange(data, 3, data.length - 1);
786
+ }
787
+ } catch (ArrayIndexOutOfBoundsException e) {
788
+ logger.error("Error: Malformed data for " + fileName);
789
+ throw new IOException("Malformed data structure");
790
+ }
791
+ }
792
+
793
+ // For all other cases, try standard decompression
794
+ try (
795
+ ByteArrayInputStream bis = new ByteArrayInputStream(data);
796
+ BrotliInputStream brotliInputStream = new BrotliInputStream(bis);
797
+ ByteArrayOutputStream bos = new ByteArrayOutputStream()
798
+ ) {
799
+ byte[] buffer = new byte[8192];
800
+ int len;
801
+ while ((len = brotliInputStream.read(buffer)) != -1) {
802
+ bos.write(buffer, 0, len);
803
+ }
804
+ return bos.toByteArray();
805
+ } catch (IOException e) {
806
+ logger.error("Error: Brotli process failed for " + fileName + ". Status: " + e.getMessage());
807
+ // Add hex dump for debugging
808
+ StringBuilder hexDump = new StringBuilder();
809
+ for (int i = 0; i < Math.min(32, data.length); i++) {
810
+ hexDump.append(String.format("%02x ", data[i]));
811
+ }
812
+ logger.error("Error: Raw data (" + fileName + "): " + hexDump.toString());
813
+ throw e;
814
+ }
815
+ }
816
+
817
+ /**
818
+ * Atomically write data to a file using OkIO
819
+ */
820
+ private void writeFileAtomic(File targetFile, InputStream inputStream, String expectedChecksum) throws IOException {
821
+ File tempFile = new File(targetFile.getParent(), targetFile.getName() + ".tmp");
822
+
823
+ try {
824
+ // Write to temp file first using OkIO
825
+ try (BufferedSink sink = Okio.buffer(Okio.sink(tempFile)); BufferedSource source = Okio.buffer(Okio.source(inputStream))) {
826
+ sink.writeAll(source);
827
+ }
828
+
829
+ // Verify checksum if provided
830
+ if (expectedChecksum != null && !expectedChecksum.isEmpty()) {
831
+ String actualChecksum = CryptoCipher.calcChecksum(tempFile);
832
+ if (!expectedChecksum.equalsIgnoreCase(actualChecksum)) {
833
+ tempFile.delete();
834
+ throw new IOException("Checksum verification failed");
835
+ }
836
+ }
837
+
838
+ // Atomic rename (on same filesystem)
839
+ Files.move(tempFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
840
+ } catch (Exception e) {
841
+ // Clean up temp file on error
842
+ if (tempFile.exists()) {
843
+ tempFile.delete();
844
+ }
845
+ throw new IOException("Failed to write file atomically: " + e.getMessage(), e);
846
+ }
847
+ }
848
+
849
+ /**
850
+ * Clean up old temporary files (both .tmp and update_*.dat files)
851
+ */
852
+ private void cleanupOldTempFiles(File directory) {
853
+ if (directory == null || !directory.exists()) return;
854
+
855
+ File[] tempFiles = directory.listFiles(
856
+ (dir, name) -> name.endsWith(".tmp") || (name.startsWith("update_") && name.endsWith(".dat"))
857
+ );
858
+ if (tempFiles != null) {
859
+ long oneHourAgo = System.currentTimeMillis() - 3600000;
860
+ for (File tempFile : tempFiles) {
861
+ if (tempFile.lastModified() < oneHourAgo) {
862
+ tempFile.delete();
863
+ }
864
+ }
865
+ }
866
+ }
126
867
  }