@capgo/capacitor-updater 5.9.4 → 5.10.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 +1030 -212
  4. package/android/build.gradle +28 -11
  5. package/android/proguard-rules.pro +22 -5
  6. package/android/src/main/AndroidManifest.xml +0 -1
  7. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +171 -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 +2 -2
  10. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +2111 -1538
  11. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1551 -0
  12. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +229 -111
  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 +42 -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 +795 -124
  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 +19 -28
  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 +1072 -162
  25. package/dist/esm/definitions.d.ts +899 -118
  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 +16 -2
  34. package/dist/esm/web.js +79 -40
  35. package/dist/esm/web.js.map +1 -1
  36. package/dist/plugin.cjs.js +361 -40
  37. package/dist/plugin.cjs.js.map +1 -1
  38. package/dist/plugin.js +361 -40
  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}/BundleStatus.swift +1 -1
  43. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1582 -0
  44. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1513 -0
  45. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +187 -0
  46. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +220 -0
  47. package/ios/Sources/CapacitorUpdaterPlugin/DeviceIdHelper.swift +120 -0
  48. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +307 -0
  49. package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
  50. package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +274 -0
  51. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +112 -0
  52. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +0 -2
  53. package/package.json +33 -28
  54. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdater.java +0 -1187
  55. package/ios/Plugin/CapacitorUpdater.swift +0 -1032
  56. package/ios/Plugin/CapacitorUpdaterPlugin.h +0 -10
  57. package/ios/Plugin/CapacitorUpdaterPlugin.m +0 -31
  58. package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -843
  59. package/ios/Plugin/CryptoCipher.swift +0 -246
  60. /package/{LICENCE → LICENSE} +0 -0
  61. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleInfo.swift +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,133 +3,804 @@
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
+ 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;
17
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
+ File targetFile = new File(destFolder, fileName);
306
+ File cacheFile = new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName());
307
+ File builtinFile = new File(builtinFolder, fileName);
308
+
309
+ // Ensure parent directories of the target file exist
310
+ if (!Objects.requireNonNull(targetFile.getParentFile()).exists() && !targetFile.getParentFile().mkdirs()) {
311
+ logger.error("Failed to create parent directory for: " + targetFile.getAbsolutePath());
312
+ hasError.set(true);
313
+ continue;
314
+ }
315
+
316
+ Future<?> future = executor.submit(() -> {
317
+ try {
318
+ if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
319
+ copyFile(builtinFile, targetFile);
320
+ logger.debug("using builtin file " + fileName);
321
+ } else if (cacheFile.exists() && verifyChecksum(cacheFile, finalFileHash)) {
322
+ copyFile(cacheFile, targetFile);
323
+ logger.debug("already cached " + fileName);
324
+ } else {
325
+ downloadAndVerify(downloadUrl, targetFile, cacheFile, finalFileHash, sessionKey, publicKey);
326
+ }
327
+
328
+ long completed = completedFiles.incrementAndGet();
329
+ int percent = calcTotalPercent(completed, totalFiles);
330
+ setProgress(percent);
331
+ } catch (Exception e) {
332
+ logger.error("Error processing file: " + fileName + " " + e.getMessage());
333
+ sendStatsAsync("download_manifest_file_fail", version + ":" + fileName);
334
+ hasError.set(true);
335
+ }
336
+ });
337
+ futures.add(future);
338
+ }
339
+
340
+ // Wait for all downloads to complete
341
+ for (Future<?> future : futures) {
342
+ try {
343
+ future.get();
344
+ } catch (Exception e) {
345
+ logger.error("Error waiting for download " + e.getMessage());
346
+ hasError.set(true);
347
+ }
348
+ }
349
+
350
+ executor.shutdown();
351
+ try {
352
+ if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
353
+ executor.shutdownNow();
354
+ }
355
+ } catch (InterruptedException e) {
356
+ executor.shutdownNow();
357
+ Thread.currentThread().interrupt();
358
+ }
359
+
360
+ if (hasError.get()) {
361
+ logger.error("One or more files failed to download");
362
+ throw new IOException("One or more files failed to download");
363
+ }
364
+
365
+ // Send stats for manifest download complete
366
+ sendStatsAsync("download_manifest_complete", version);
367
+ } catch (Exception e) {
368
+ logger.error("Error in handleManifestDownload " + e.getMessage());
369
+ throw new RuntimeException(e.getLocalizedMessage());
370
+ }
371
+ }
372
+
373
+ private void handleSingleFileDownload(
374
+ String url,
375
+ String id,
376
+ String documentsDir,
377
+ String dest,
378
+ String version,
379
+ String sessionKey,
380
+ String checksum
381
+ ) {
382
+ // Send stats for zip download start
383
+ sendStatsAsync("download_zip_start", version);
384
+
385
+ File target = new File(documentsDir, dest);
386
+ File infoFile = new File(documentsDir, UPDATE_FILE);
387
+ File tempFile = new File(documentsDir, "temp" + ".tmp");
388
+
389
+ // Check available disk space before starting
390
+ long availableSpace = target.getParentFile().getUsableSpace();
391
+ long estimatedSize = 50 * 1024 * 1024; // 50MB default estimate
392
+ if (availableSpace < estimatedSize * 2) {
393
+ throw new RuntimeException("insufficient_disk_space");
394
+ }
395
+
396
+ HttpURLConnection httpConn = null;
397
+ InputStream inputStream = null;
398
+ FileOutputStream outputStream = null;
399
+ BufferedReader reader = null;
400
+ BufferedWriter writer = null;
401
+
402
+ try {
403
+ URL u = new URL(url);
404
+ httpConn = (HttpURLConnection) u.openConnection();
405
+
406
+ // Set reasonable timeouts
407
+ httpConn.setConnectTimeout(30000); // 30 seconds
408
+ httpConn.setReadTimeout(60000); // 60 seconds
409
+
410
+ // Reading progress file (if exist)
411
+ long downloadedBytes = 0;
412
+
413
+ if (infoFile.exists() && tempFile.exists()) {
414
+ try {
415
+ reader = new BufferedReader(new FileReader(infoFile));
416
+ String updateVersion = reader.readLine();
417
+ if (updateVersion != null && !updateVersion.equals(version)) {
418
+ clearDownloadData(documentsDir);
419
+ } else {
420
+ downloadedBytes = tempFile.length();
421
+ }
422
+ } finally {
423
+ if (reader != null) {
424
+ try {
425
+ reader.close();
426
+ } catch (Exception ignored) {}
427
+ }
428
+ }
429
+ } else {
430
+ clearDownloadData(documentsDir);
431
+ }
432
+
433
+ if (downloadedBytes > 0) {
434
+ httpConn.setRequestProperty("Range", "bytes=" + downloadedBytes + "-");
435
+ }
436
+
437
+ int responseCode = httpConn.getResponseCode();
438
+
439
+ if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_PARTIAL) {
440
+ long contentLength = httpConn.getContentLength() + downloadedBytes;
441
+
442
+ // Check if we have enough space for the actual file
443
+ if (contentLength > 0 && availableSpace < contentLength * 2) {
444
+ throw new RuntimeException("insufficient_disk_space");
445
+ }
18
446
 
19
- public class DownloadService extends IntentService {
20
-
21
- public static final String URL = "URL";
22
- public static final String ID = "id";
23
- public static final String PERCENT = "percent";
24
- public static final String FILEDEST = "filendest";
25
- public static final String DOCDIR = "docdir";
26
- public static final String ERROR = "error";
27
- public static final String VERSION = "version";
28
- public static final String SESSIONKEY = "sessionkey";
29
- public static final String CHECKSUM = "checksum";
30
- public static final String NOTIFICATION = "service receiver";
31
- public static final String PERCENTDOWNLOAD = "percent receiver";
32
-
33
- public DownloadService() {
34
- super("Background DownloadService");
35
- }
36
-
37
- private int calcTotalPercent(
38
- final int percent,
39
- final int min,
40
- final int max
41
- ) {
42
- return (percent * (max - min)) / 100 + min;
43
- }
44
-
45
- // Will be called asynchronously by OS.
46
- @Override
47
- protected void onHandleIntent(Intent intent) {
48
- assert intent != null;
49
- String url = intent.getStringExtra(URL);
50
- String id = intent.getStringExtra(ID);
51
- String documentsDir = intent.getStringExtra(DOCDIR);
52
- String dest = intent.getStringExtra(FILEDEST);
53
- String version = intent.getStringExtra(VERSION);
54
- String sessionKey = intent.getStringExtra(SESSIONKEY);
55
- String checksum = intent.getStringExtra(CHECKSUM);
56
-
57
- try {
58
- final URL u = new URL(url);
59
- final URLConnection connection = u.openConnection();
60
-
61
- try (
62
- final InputStream is = u.openStream();
63
- final DataInputStream dis = new DataInputStream(is)
64
- ) {
65
- assert dest != null;
66
- final File target = new File(documentsDir, dest);
67
- Objects.requireNonNull(target.getParentFile()).mkdirs();
68
- target.createNewFile();
69
- try (final FileOutputStream fos = new FileOutputStream(target)) {
70
- final long totalLength = connection.getContentLength();
71
- final int bufferSize = 1024;
72
- final byte[] buffer = new byte[bufferSize];
73
- int length;
74
-
75
- int bytesRead = bufferSize;
76
- int percent = 0;
77
- this.notifyDownload(id, 10);
78
- while ((length = dis.read(buffer)) > 0) {
79
- fos.write(buffer, 0, length);
80
- final int newPercent = (int) ((bytesRead * 100) / totalLength);
81
- if (totalLength > 1 && newPercent != percent) {
82
- percent = newPercent;
83
- this.notifyDownload(id, this.calcTotalPercent(percent, 10, 70));
84
- }
85
- bytesRead += length;
86
- }
87
- publishResults(dest, id, version, checksum, sessionKey, "");
88
- }
89
- }
90
- } catch (OutOfMemoryError e) {
91
- e.printStackTrace();
92
- publishResults("", id, version, checksum, sessionKey, "low_mem_fail");
93
- } catch (Exception e) {
94
- e.printStackTrace();
95
- publishResults(
96
- "",
97
- id,
98
- version,
99
- checksum,
100
- sessionKey,
101
- e.getLocalizedMessage()
102
- );
103
- }
104
- }
105
-
106
- private void notifyDownload(String id, int percent) {
107
- Intent intent = new Intent(PERCENTDOWNLOAD);
108
- intent.putExtra(ID, id);
109
- intent.putExtra(PERCENT, percent);
110
- sendBroadcast(intent);
111
- }
112
-
113
- private void publishResults(
114
- String dest,
115
- String id,
116
- String version,
117
- String checksum,
118
- String sessionKey,
119
- String error
120
- ) {
121
- Intent intent = new Intent(NOTIFICATION);
122
- if (dest != null && !dest.isEmpty()) {
123
- intent.putExtra(FILEDEST, dest);
124
- }
125
- if (error != null && !error.isEmpty()) {
126
- intent.putExtra(ERROR, error);
127
- }
128
- intent.putExtra(ID, id);
129
- intent.putExtra(VERSION, version);
130
- intent.putExtra(SESSIONKEY, sessionKey);
131
- intent.putExtra(CHECKSUM, checksum);
132
- intent.putExtra(ERROR, error);
133
- sendBroadcast(intent);
134
- }
447
+ try {
448
+ inputStream = httpConn.getInputStream();
449
+ outputStream = new FileOutputStream(tempFile, downloadedBytes > 0);
450
+
451
+ if (downloadedBytes == 0) {
452
+ writer = new BufferedWriter(new FileWriter(infoFile));
453
+ writer.write(String.valueOf(version));
454
+ writer.close();
455
+ writer = null;
456
+ }
457
+
458
+ byte[] buffer = new byte[8192]; // Larger buffer for better performance
459
+ int lastNotifiedPercent = 0;
460
+ int bytesRead;
461
+
462
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
463
+ outputStream.write(buffer, 0, bytesRead);
464
+ downloadedBytes += bytesRead;
465
+
466
+ // Flush every 1MB to ensure progress is saved
467
+ if (downloadedBytes % (1024 * 1024) == 0) {
468
+ outputStream.flush();
469
+ }
470
+
471
+ // Computing percentage
472
+ int percent = calcTotalPercent(downloadedBytes, contentLength);
473
+ if (percent >= lastNotifiedPercent + 10) {
474
+ lastNotifiedPercent = (percent / 10) * 10;
475
+ setProgress(lastNotifiedPercent);
476
+ }
477
+ }
478
+
479
+ // Final flush
480
+ outputStream.flush();
481
+ outputStream.close();
482
+ outputStream = null;
483
+
484
+ inputStream.close();
485
+ inputStream = null;
486
+
487
+ // Rename the temp file with the final name (dest)
488
+ if (!tempFile.renameTo(new File(documentsDir, dest))) {
489
+ throw new RuntimeException("Failed to rename temp file to final destination");
490
+ }
491
+ infoFile.delete();
492
+
493
+ // Send stats for zip download complete
494
+ sendStatsAsync("download_zip_complete", version);
495
+ } catch (OutOfMemoryError e) {
496
+ logger.error("Out of memory during download: " + e.getMessage());
497
+ // Try to free some memory
498
+ System.gc();
499
+ throw new RuntimeException("low_mem_fail");
500
+ } finally {
501
+ // Ensure all resources are closed
502
+ if (outputStream != null) {
503
+ try {
504
+ outputStream.close();
505
+ } catch (Exception ignored) {}
506
+ }
507
+ if (inputStream != null) {
508
+ try {
509
+ inputStream.close();
510
+ } catch (Exception ignored) {}
511
+ }
512
+ if (writer != null) {
513
+ try {
514
+ writer.close();
515
+ } catch (Exception ignored) {}
516
+ }
517
+ }
518
+ } else {
519
+ infoFile.delete();
520
+ throw new RuntimeException("HTTP error: " + responseCode);
521
+ }
522
+ } catch (OutOfMemoryError e) {
523
+ logger.error("Critical memory error: " + e.getMessage());
524
+ System.gc(); // Suggest garbage collection
525
+ throw new RuntimeException("low_mem_fail");
526
+ } catch (SecurityException e) {
527
+ logger.error("Security error during download: " + e.getMessage());
528
+ throw new RuntimeException("security_error: " + e.getMessage());
529
+ } catch (Exception e) {
530
+ logger.error("Download error: " + e.getMessage());
531
+ throw new RuntimeException(e.getMessage());
532
+ } finally {
533
+ // Ensure connection is closed
534
+ if (httpConn != null) {
535
+ try {
536
+ httpConn.disconnect();
537
+ } catch (Exception ignored) {}
538
+ }
539
+ }
540
+ }
541
+
542
+ private void clearDownloadData(String docDir) {
543
+ File tempFile = new File(docDir, "temp" + ".tmp");
544
+ File infoFile = new File(docDir, UPDATE_FILE);
545
+ try {
546
+ tempFile.delete();
547
+ infoFile.delete();
548
+ infoFile.createNewFile();
549
+ tempFile.createNewFile();
550
+ } catch (IOException e) {
551
+ logger.error("Error in clearDownloadData " + e.getMessage());
552
+ // not a fatal error, so we don't throw an exception
553
+ }
554
+ }
555
+
556
+ // Helper methods
557
+
558
+ private void copyFile(File source, File dest) throws IOException {
559
+ try (
560
+ FileInputStream inStream = new FileInputStream(source);
561
+ FileOutputStream outStream = new FileOutputStream(dest);
562
+ FileChannel inChannel = inStream.getChannel();
563
+ FileChannel outChannel = outStream.getChannel()
564
+ ) {
565
+ inChannel.transferTo(0, inChannel.size(), outChannel);
566
+ }
567
+ }
568
+
569
+ private void downloadAndVerify(
570
+ String downloadUrl,
571
+ File targetFile,
572
+ File cacheFile,
573
+ String expectedHash,
574
+ String sessionKey,
575
+ String publicKey
576
+ ) throws Exception {
577
+ logger.debug("downloadAndVerify " + downloadUrl);
578
+
579
+ Request request = new Request.Builder().url(downloadUrl).build();
580
+
581
+ // Check if file is a Brotli file
582
+ boolean isBrotli = targetFile.getName().endsWith(".br");
583
+
584
+ // Create final target file with .br extension removed if it's a Brotli file
585
+ File finalTargetFile = isBrotli
586
+ ? new File(targetFile.getParentFile(), targetFile.getName().substring(0, targetFile.getName().length() - 3))
587
+ : targetFile;
588
+
589
+ // Create a temporary file for the compressed data
590
+ File compressedFile = new File(getApplicationContext().getCacheDir(), "temp_" + targetFile.getName() + ".tmp");
591
+
592
+ try (Response response = sharedClient.newCall(request).execute()) {
593
+ if (!response.isSuccessful()) {
594
+ sendStatsAsync("download_manifest_file_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
595
+ throw new IOException("Unexpected response code: " + response.code());
596
+ }
597
+
598
+ // Download compressed file atomically
599
+ ResponseBody responseBody = response.body();
600
+ if (responseBody == null) {
601
+ throw new IOException("Response body is null");
602
+ }
603
+
604
+ // Use OkIO for atomic write
605
+ writeFileAtomic(compressedFile, responseBody.byteStream(), null);
606
+
607
+ if (!publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
608
+ logger.debug("Decrypting file " + targetFile.getName());
609
+ CryptoCipher.decryptFile(compressedFile, publicKey, sessionKey);
610
+ }
611
+
612
+ // Only decompress if file has .br extension
613
+ if (isBrotli) {
614
+ // Use new decompression method with atomic write
615
+ try (FileInputStream fis = new FileInputStream(compressedFile)) {
616
+ byte[] compressedData = new byte[(int) compressedFile.length()];
617
+ fis.read(compressedData);
618
+ byte[] decompressedData;
619
+ try {
620
+ decompressedData = decompressBrotli(compressedData, targetFile.getName());
621
+ } catch (IOException e) {
622
+ sendStatsAsync(
623
+ "download_manifest_brotli_fail",
624
+ getInputData().getString(VERSION) + ":" + finalTargetFile.getName()
625
+ );
626
+ throw e;
627
+ }
628
+
629
+ // Write decompressed data atomically
630
+ try (java.io.ByteArrayInputStream bais = new java.io.ByteArrayInputStream(decompressedData)) {
631
+ writeFileAtomic(finalTargetFile, bais, null);
632
+ }
633
+ }
634
+ } else {
635
+ // Just copy the file without decompression using atomic operation
636
+ try (FileInputStream fis = new FileInputStream(compressedFile)) {
637
+ writeFileAtomic(finalTargetFile, fis, null);
638
+ }
639
+ }
640
+
641
+ // Delete the compressed file
642
+ compressedFile.delete();
643
+ String calculatedHash = CryptoCipher.calcChecksum(finalTargetFile);
644
+
645
+ // Verify checksum
646
+ if (calculatedHash.equals(expectedHash)) {
647
+ // Only cache if checksum is correct - use atomic copy
648
+ try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
649
+ writeFileAtomic(cacheFile, fis, expectedHash);
650
+ }
651
+ } else {
652
+ finalTargetFile.delete();
653
+ sendStatsAsync("download_manifest_checksum_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
654
+ throw new IOException(
655
+ "Checksum verification failed for: " +
656
+ downloadUrl +
657
+ " " +
658
+ targetFile.getName() +
659
+ " expected: " +
660
+ expectedHash +
661
+ " calculated: " +
662
+ calculatedHash
663
+ );
664
+ }
665
+ } catch (Exception e) {
666
+ throw new IOException("Error in downloadAndVerify: " + e.getMessage());
667
+ }
668
+ }
669
+
670
+ private boolean verifyChecksum(File file, String expectedHash) {
671
+ try {
672
+ String actualHash = calculateFileHash(file);
673
+ return actualHash.equals(expectedHash);
674
+ } catch (Exception e) {
675
+ e.printStackTrace();
676
+ return false;
677
+ }
678
+ }
679
+
680
+ private String calculateFileHash(File file) throws Exception {
681
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
682
+ byte[] byteArray = new byte[1024];
683
+ int bytesCount = 0;
684
+
685
+ try (FileInputStream fis = new FileInputStream(file)) {
686
+ while ((bytesCount = fis.read(byteArray)) != -1) {
687
+ digest.update(byteArray, 0, bytesCount);
688
+ }
689
+ }
690
+
691
+ byte[] bytes = digest.digest();
692
+ StringBuilder sb = new StringBuilder();
693
+ for (byte aByte : bytes) {
694
+ sb.append(Integer.toString((aByte & 0xff) + 0x100, 16).substring(1));
695
+ }
696
+ return sb.toString();
697
+ }
698
+
699
+ private byte[] decompressBrotli(byte[] data, String fileName) throws IOException {
700
+ // Validate input
701
+ if (data == null) {
702
+ logger.error("Error: Null data received for " + fileName);
703
+ throw new IOException("Null data received");
704
+ }
705
+
706
+ // Handle empty files
707
+ if (data.length == 0) {
708
+ return new byte[0];
709
+ }
710
+
711
+ // Handle the special EMPTY_BROTLI_STREAM case
712
+ if (data.length == 3 && data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06) {
713
+ return new byte[0];
714
+ }
715
+
716
+ // For small files, check if it's a minimal Brotli wrapper
717
+ if (data.length > 3) {
718
+ try {
719
+ // Handle our minimal wrapper pattern
720
+ if (data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 && data[data.length - 1] == 0x03) {
721
+ return Arrays.copyOfRange(data, 3, data.length - 1);
722
+ }
723
+
724
+ // Handle brotli.compress minimal wrapper (quality 0)
725
+ if (data[0] == 0x0b && data[1] == 0x02 && data[2] == (byte) 0x80 && data[data.length - 1] == 0x03) {
726
+ return Arrays.copyOfRange(data, 3, data.length - 1);
727
+ }
728
+ } catch (ArrayIndexOutOfBoundsException e) {
729
+ logger.error("Error: Malformed data for " + fileName);
730
+ throw new IOException("Malformed data structure");
731
+ }
732
+ }
733
+
734
+ // For all other cases, try standard decompression
735
+ try (
736
+ ByteArrayInputStream bis = new ByteArrayInputStream(data);
737
+ BrotliInputStream brotliInputStream = new BrotliInputStream(bis);
738
+ ByteArrayOutputStream bos = new ByteArrayOutputStream()
739
+ ) {
740
+ byte[] buffer = new byte[8192];
741
+ int len;
742
+ while ((len = brotliInputStream.read(buffer)) != -1) {
743
+ bos.write(buffer, 0, len);
744
+ }
745
+ return bos.toByteArray();
746
+ } catch (IOException e) {
747
+ logger.error("Error: Brotli process failed for " + fileName + ". Status: " + e.getMessage());
748
+ // Add hex dump for debugging
749
+ StringBuilder hexDump = new StringBuilder();
750
+ for (int i = 0; i < Math.min(32, data.length); i++) {
751
+ hexDump.append(String.format("%02x ", data[i]));
752
+ }
753
+ logger.error("Error: Raw data (" + fileName + "): " + hexDump.toString());
754
+ throw e;
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Atomically write data to a file using OkIO
760
+ */
761
+ private void writeFileAtomic(File targetFile, InputStream inputStream, String expectedChecksum) throws IOException {
762
+ File tempFile = new File(targetFile.getParent(), targetFile.getName() + ".tmp");
763
+
764
+ try {
765
+ // Write to temp file first using OkIO
766
+ try (BufferedSink sink = Okio.buffer(Okio.sink(tempFile)); BufferedSource source = Okio.buffer(Okio.source(inputStream))) {
767
+ sink.writeAll(source);
768
+ }
769
+
770
+ // Verify checksum if provided
771
+ if (expectedChecksum != null && !expectedChecksum.isEmpty()) {
772
+ String actualChecksum = CryptoCipher.calcChecksum(tempFile);
773
+ if (!expectedChecksum.equalsIgnoreCase(actualChecksum)) {
774
+ tempFile.delete();
775
+ throw new IOException("Checksum verification failed");
776
+ }
777
+ }
778
+
779
+ // Atomic rename (on same filesystem)
780
+ Files.move(tempFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
781
+ } catch (Exception e) {
782
+ // Clean up temp file on error
783
+ if (tempFile.exists()) {
784
+ tempFile.delete();
785
+ }
786
+ throw new IOException("Failed to write file atomically: " + e.getMessage(), e);
787
+ }
788
+ }
789
+
790
+ /**
791
+ * Clean up old temporary files
792
+ */
793
+ private void cleanupOldTempFiles(File directory) {
794
+ if (directory == null || !directory.exists()) return;
795
+
796
+ File[] tempFiles = directory.listFiles((dir, name) -> name.endsWith(".tmp"));
797
+ if (tempFiles != null) {
798
+ long oneHourAgo = System.currentTimeMillis() - 3600000;
799
+ for (File tempFile : tempFiles) {
800
+ if (tempFile.lastModified() < oneHourAgo) {
801
+ tempFile.delete();
802
+ }
803
+ }
804
+ }
805
+ }
135
806
  }