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