@capgo/cordova-updater 8.1.1
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/LICENSE +373 -0
- package/README.md +82 -0
- package/dist/esm/definitions.d.ts +2684 -0
- package/dist/esm/definitions.js +109 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/exec.d.ts +4 -0
- package/dist/esm/exec.js +25 -0
- package/dist/esm/exec.js.map +1 -0
- 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 +57 -0
- package/dist/esm/index.js +68 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/plugin.d.ts +57 -0
- package/dist/esm/plugin.js +15 -0
- package/dist/esm/plugin.js.map +1 -0
- package/dist/plugin.cjs.js +217 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +222 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +96 -0
- package/plugin.xml +207 -0
- package/src/android/CordovaUpdaterPlugin.java +5662 -0
- package/src/android/UpdaterPathHandler.java +99 -0
- package/src/android/app/capgo/cordova/updater/AndroidAppExitReporter.java +92 -0
- package/src/android/app/capgo/cordova/updater/AppLifecycleObserver.java +115 -0
- package/src/android/app/capgo/cordova/updater/BundleInfo.java +246 -0
- package/src/android/app/capgo/cordova/updater/BundleStatus.java +45 -0
- package/src/android/app/capgo/cordova/updater/Callback.java +13 -0
- package/src/android/app/capgo/cordova/updater/CapgoUpdater.java +3404 -0
- package/src/android/app/capgo/cordova/updater/CordovaUpdaterConfig.java +56 -0
- package/src/android/app/capgo/cordova/updater/CryptoCipher.java +462 -0
- package/src/android/app/capgo/cordova/updater/DataManager.java +44 -0
- package/src/android/app/capgo/cordova/updater/DelayCondition.java +56 -0
- package/src/android/app/capgo/cordova/updater/DelayUntilNext.java +14 -0
- package/src/android/app/capgo/cordova/updater/DelayUpdateUtils.java +307 -0
- package/src/android/app/capgo/cordova/updater/DeviceIdHelper.java +230 -0
- package/src/android/app/capgo/cordova/updater/DownloadService.java +1219 -0
- package/src/android/app/capgo/cordova/updater/DownloadWorkerManager.java +260 -0
- package/src/android/app/capgo/cordova/updater/InternalUtils.java +54 -0
- package/src/android/app/capgo/cordova/updater/Logger.java +345 -0
- package/src/android/app/capgo/cordova/updater/ShakeDetector.java +72 -0
- package/src/android/app/capgo/cordova/updater/ShakeMenu.java +39 -0
- package/src/android/app/capgo/cordova/updater/ThreeFingerPinchDetector.java +323 -0
- package/src/android/app/capgo/cordova/updater/compat/JSArray.java +9 -0
- package/src/android/app/capgo/cordova/updater/compat/JSObject.java +83 -0
- package/src/android/app/capgo/cordova/updater/compat/PluginCall.java +165 -0
- package/src/android/capgo-cordova-updater.gradle +19 -0
- package/src/android/capgo-updater.xml +3 -0
- package/src/android/test/app/capgo/cordova/updater/CapacitorUpdaterUnitTest.java.skip +3039 -0
- package/src/android/test/app/capgo/cordova/updater/DelayUpdateUtilsTest.java +177 -0
- package/src/android/test/app/capgo/cordova/updater/NativeContractTest.java +105 -0
- package/src/ios/AES.swift +216 -0
- package/src/ios/AppHealthTracker.swift +82 -0
- package/src/ios/BundleInfo.swift +185 -0
- package/src/ios/BundleStatus.swift +127 -0
- package/src/ios/CapgoRawRsa.swift +36 -0
- package/src/ios/CapgoUpdater.swift +3544 -0
- package/src/ios/CordovaCompat.swift +11 -0
- package/src/ios/CordovaPluginCall.swift +190 -0
- package/src/ios/CordovaUpdaterCommands.swift +270 -0
- package/src/ios/CordovaUpdaterPlugin.swift +4706 -0
- package/src/ios/CryptoCipher.swift +326 -0
- package/src/ios/DelayCondition.swift +74 -0
- package/src/ios/DelayUntilNext.swift +30 -0
- package/src/ios/DelayUpdateUtils.swift +257 -0
- package/src/ios/DeviceIdHelper.swift +120 -0
- package/src/ios/Info.plist +28 -0
- package/src/ios/InternalUtils.swift +471 -0
- package/src/ios/Logger.swift +283 -0
- package/src/ios/RSA.swift +96 -0
- package/src/ios/ShakeMenu.swift +52 -0
- package/src/ios/UpdaterSchemeHandler.swift +81 -0
- package/src/ios/UserDefaultsExtension.swift +46 -0
- package/src/ios/WebViewStatsReporter.swift +303 -0
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
5
|
+
*/
|
|
6
|
+
package app.capgo.cordova.updater;
|
|
7
|
+
|
|
8
|
+
import android.content.Context;
|
|
9
|
+
import android.content.res.AssetManager;
|
|
10
|
+
import androidx.annotation.NonNull;
|
|
11
|
+
import androidx.work.Data;
|
|
12
|
+
import androidx.work.Worker;
|
|
13
|
+
import androidx.work.WorkerParameters;
|
|
14
|
+
import java.io.*;
|
|
15
|
+
import java.io.FileInputStream;
|
|
16
|
+
import java.net.HttpURLConnection;
|
|
17
|
+
import java.net.URL;
|
|
18
|
+
import java.nio.channels.FileChannel;
|
|
19
|
+
import java.security.MessageDigest;
|
|
20
|
+
import java.util.ArrayList;
|
|
21
|
+
import java.util.Arrays;
|
|
22
|
+
import java.util.HashSet;
|
|
23
|
+
import java.util.List;
|
|
24
|
+
import java.util.Objects;
|
|
25
|
+
import java.util.Set;
|
|
26
|
+
import java.util.UUID;
|
|
27
|
+
import java.util.concurrent.ExecutorService;
|
|
28
|
+
import java.util.concurrent.Executors;
|
|
29
|
+
import java.util.concurrent.Future;
|
|
30
|
+
import java.util.concurrent.TimeUnit;
|
|
31
|
+
import java.util.concurrent.atomic.AtomicBoolean;
|
|
32
|
+
import java.util.concurrent.atomic.AtomicLong;
|
|
33
|
+
import okhttp3.Call;
|
|
34
|
+
import okhttp3.Callback;
|
|
35
|
+
import okhttp3.Dispatcher;
|
|
36
|
+
import okhttp3.Interceptor;
|
|
37
|
+
import okhttp3.MediaType;
|
|
38
|
+
import okhttp3.OkHttpClient;
|
|
39
|
+
import okhttp3.Protocol;
|
|
40
|
+
import okhttp3.Request;
|
|
41
|
+
import okhttp3.RequestBody;
|
|
42
|
+
import okhttp3.Response;
|
|
43
|
+
import okhttp3.ResponseBody;
|
|
44
|
+
import org.brotli.dec.BrotliInputStream;
|
|
45
|
+
import org.json.JSONArray;
|
|
46
|
+
import org.json.JSONObject;
|
|
47
|
+
|
|
48
|
+
public class DownloadService extends Worker {
|
|
49
|
+
|
|
50
|
+
private static Logger logger;
|
|
51
|
+
|
|
52
|
+
public static void setLogger(Logger loggerInstance) {
|
|
53
|
+
logger = loggerInstance;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public static final String URL = "URL";
|
|
57
|
+
public static final String ID = "id";
|
|
58
|
+
public static final String PERCENT = "percent";
|
|
59
|
+
public static final String FILEDEST = "filendest";
|
|
60
|
+
public static final String DOCDIR = "docdir";
|
|
61
|
+
public static final String ERROR = "error";
|
|
62
|
+
public static final String VERSION = "version";
|
|
63
|
+
public static final String SESSIONKEY = "sessionkey";
|
|
64
|
+
public static final String CHECKSUM = "checksum";
|
|
65
|
+
public static final String PUBLIC_KEY = "publickey";
|
|
66
|
+
public static final String IS_MANIFEST = "is_manifest";
|
|
67
|
+
public static final String APP_ID = "app_id";
|
|
68
|
+
public static final String pluginVersion = "plugin_version";
|
|
69
|
+
public static final String INSTALL_SOURCE = "install_source";
|
|
70
|
+
public static final String STATS_URL = "stats_url";
|
|
71
|
+
public static final String DEVICE_ID = "device_id";
|
|
72
|
+
public static final String CUSTOM_ID = "custom_id";
|
|
73
|
+
public static final String VERSION_BUILD = "version_build";
|
|
74
|
+
public static final String VERSION_CODE = "version_code";
|
|
75
|
+
public static final String VERSION_OS = "version_os";
|
|
76
|
+
public static final String DEFAULT_CHANNEL = "default_channel";
|
|
77
|
+
public static final String IS_PROD = "is_prod";
|
|
78
|
+
public static final String IS_EMULATOR = "is_emulator";
|
|
79
|
+
// HTTP + decode share one pool. Cap per host by CPU: 8 on 4 cores, 16 on 8 cores.
|
|
80
|
+
// Keep the global cap at 64 so API calls on another host are not starved by manifest downloads.
|
|
81
|
+
private static final int MANIFEST_MAX_CONCURRENT_FILES = manifestMaxConcurrentFiles();
|
|
82
|
+
private static final int SHARED_MAX_REQUESTS = 64;
|
|
83
|
+
private static final String UPDATE_FILE = "update.dat";
|
|
84
|
+
|
|
85
|
+
// Shared OkHttpClient to prevent resource leaks
|
|
86
|
+
protected static volatile OkHttpClient sharedClient;
|
|
87
|
+
private static final Object HTTP_CLIENT_LOCK = new Object();
|
|
88
|
+
// Match CapgoUpdater.timeout / responseTimeout default (20s). OkHttp's 10s
|
|
89
|
+
// defaults were unused by the plugin config and aborted slow manifest GETs.
|
|
90
|
+
private static volatile int httpTimeoutMs = 20_000;
|
|
91
|
+
private static String currentAppId = "unknown";
|
|
92
|
+
private static String currentPluginVersion = "unknown";
|
|
93
|
+
private static String currentVersionOs = "unknown";
|
|
94
|
+
|
|
95
|
+
// Initialize shared client with User-Agent interceptor
|
|
96
|
+
static {
|
|
97
|
+
Dispatcher dispatcher = new Dispatcher();
|
|
98
|
+
dispatcher.setMaxRequests(SHARED_MAX_REQUESTS);
|
|
99
|
+
dispatcher.setMaxRequestsPerHost(MANIFEST_MAX_CONCURRENT_FILES);
|
|
100
|
+
sharedClient = new OkHttpClient.Builder()
|
|
101
|
+
.dispatcher(dispatcher)
|
|
102
|
+
.protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1))
|
|
103
|
+
.connectTimeout(httpTimeoutMs, TimeUnit.MILLISECONDS)
|
|
104
|
+
.readTimeout(httpTimeoutMs, TimeUnit.MILLISECONDS)
|
|
105
|
+
.writeTimeout(httpTimeoutMs, TimeUnit.MILLISECONDS)
|
|
106
|
+
.addInterceptor((chain) -> {
|
|
107
|
+
Request originalRequest = chain.request();
|
|
108
|
+
String userAgent = buildUserAgent(currentAppId, currentPluginVersion, currentVersionOs);
|
|
109
|
+
Request requestWithUserAgent = originalRequest.newBuilder().header("User-Agent", userAgent).build();
|
|
110
|
+
return chain.proceed(requestWithUserAgent);
|
|
111
|
+
})
|
|
112
|
+
.build();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
static int httpTimeoutMs() {
|
|
116
|
+
return httpTimeoutMs;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
static void applyHttpTimeouts(int timeoutMs) {
|
|
120
|
+
// OkHttp treats 0 as infinite; keep plugin responseTimeout floor (20s default).
|
|
121
|
+
int ms = timeoutMs > 0 ? timeoutMs : 20_000;
|
|
122
|
+
synchronized (HTTP_CLIENT_LOCK) {
|
|
123
|
+
if (
|
|
124
|
+
sharedClient.connectTimeoutMillis() == ms &&
|
|
125
|
+
sharedClient.readTimeoutMillis() == ms &&
|
|
126
|
+
sharedClient.writeTimeoutMillis() == ms
|
|
127
|
+
) {
|
|
128
|
+
httpTimeoutMs = ms;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
sharedClient = sharedClient
|
|
132
|
+
.newBuilder()
|
|
133
|
+
.connectTimeout(ms, TimeUnit.MILLISECONDS)
|
|
134
|
+
.readTimeout(ms, TimeUnit.MILLISECONDS)
|
|
135
|
+
.writeTimeout(ms, TimeUnit.MILLISECONDS)
|
|
136
|
+
.build();
|
|
137
|
+
httpTimeoutMs = ms;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
static int manifestMaxConcurrentFiles() {
|
|
142
|
+
return manifestMaxConcurrentFiles(Runtime.getRuntime().availableProcessors());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
static int manifestMaxConcurrentFiles(int processors) {
|
|
146
|
+
int cores = Math.max(1, processors);
|
|
147
|
+
return Math.min(64, Math.max(8, cores * 2));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static String buildUserAgent(String appId, String pluginVersion, String versionOs) {
|
|
151
|
+
return (
|
|
152
|
+
"CapacitorUpdater/" +
|
|
153
|
+
sanitizeUserAgentValue(pluginVersion) +
|
|
154
|
+
" (" +
|
|
155
|
+
sanitizeUserAgentValue(appId) +
|
|
156
|
+
") android/" +
|
|
157
|
+
sanitizeUserAgentValue(versionOs)
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private static String sanitizeUserAgentValue(String value) {
|
|
162
|
+
if (value == null || value.isEmpty()) {
|
|
163
|
+
return "unknown";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
StringBuilder sanitized = new StringBuilder();
|
|
167
|
+
value.codePoints().forEach((cp) -> {
|
|
168
|
+
boolean isVisibleAscii = cp >= 0x20 && cp <= 0x7E;
|
|
169
|
+
boolean isIso88591 = cp >= 0xA0 && cp <= 0xFF;
|
|
170
|
+
if (isVisibleAscii || isIso88591) {
|
|
171
|
+
sanitized.appendCodePoint(cp);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
String result = sanitized.toString().trim();
|
|
176
|
+
return result.isEmpty() ? "unknown" : result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Method to update User-Agent values
|
|
180
|
+
public static void updateUserAgent(String appId, String pluginVersion, String versionOs) {
|
|
181
|
+
currentAppId = sanitizeUserAgentValue(appId);
|
|
182
|
+
currentPluginVersion = sanitizeUserAgentValue(pluginVersion);
|
|
183
|
+
currentVersionOs = sanitizeUserAgentValue(versionOs);
|
|
184
|
+
if (logger != null) {
|
|
185
|
+
logger.debug("Updated User-Agent: " + buildUserAgent(currentAppId, currentPluginVersion, currentVersionOs));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
public DownloadService(@NonNull Context context, @NonNull WorkerParameters params) {
|
|
190
|
+
super(context, params);
|
|
191
|
+
// Use shared client - no need to create new instances
|
|
192
|
+
|
|
193
|
+
// Clean up old temporary files on service initialization
|
|
194
|
+
cleanupOldTempFiles(getApplicationContext().getCacheDir());
|
|
195
|
+
cleanupOldTempFiles(new File(getApplicationContext().getCacheDir(), "capgo_downloads"));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private void setProgress(int percent) {
|
|
199
|
+
Data progress = new Data.Builder().putInt(PERCENT, percent).build();
|
|
200
|
+
setProgressAsync(progress);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private Result createFailureResult(String error) {
|
|
204
|
+
Data output = new Data.Builder().putString(ERROR, error).build();
|
|
205
|
+
return Result.failure(output);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private Result createSuccessResult(String dest, String version, String sessionKey, String checksum, boolean isManifest) {
|
|
209
|
+
Data output = new Data.Builder()
|
|
210
|
+
.putString(FILEDEST, dest)
|
|
211
|
+
.putString(VERSION, version)
|
|
212
|
+
.putString(SESSIONKEY, sessionKey)
|
|
213
|
+
.putString(CHECKSUM, checksum)
|
|
214
|
+
.putBoolean(IS_MANIFEST, isManifest)
|
|
215
|
+
.build();
|
|
216
|
+
return Result.success(output);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
static File resolveManifestTargetFile(final File destFolder, final String fileName) throws IOException {
|
|
220
|
+
final boolean isBrotli = fileName.endsWith(".br");
|
|
221
|
+
final String targetFileName = isBrotli ? fileName.substring(0, fileName.length() - 3) : fileName;
|
|
222
|
+
return CapgoUpdater.resolvePathInsideDirectory(destFolder, targetFileName);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
static File resolveManifestBuiltinFile(final File builtinFolder, final String fileName) throws IOException {
|
|
226
|
+
final boolean isBrotli = fileName.endsWith(".br");
|
|
227
|
+
final String resolvedName = isBrotli ? fileName.substring(0, fileName.length() - 3) : fileName;
|
|
228
|
+
return CapgoUpdater.resolvePathInsideDirectory(builtinFolder, resolvedName);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** APK web assets live in assets/public/; strip .br so store files match. */
|
|
232
|
+
static String resolveBuiltinAssetPath(final String fileName) throws IOException {
|
|
233
|
+
final File base = new File("/capgo-builtin-assets");
|
|
234
|
+
final File resolved = resolveManifestBuiltinFile(base, fileName);
|
|
235
|
+
final String basePath = base.getCanonicalPath();
|
|
236
|
+
final String resolvedPath = resolved.getCanonicalPath();
|
|
237
|
+
final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
|
|
238
|
+
if (!resolvedPath.startsWith(normalizedBasePath)) {
|
|
239
|
+
throw new IOException("Invalid manifest file path: " + fileName);
|
|
240
|
+
}
|
|
241
|
+
return "public/" + resolvedPath.substring(normalizedBasePath.length()).replace(File.separatorChar, '/');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
static boolean copyStreamIfChecksumMatches(final InputStream input, final File dest, final String expectedHash) throws IOException {
|
|
245
|
+
if (expectedHash == null || expectedHash.isEmpty()) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
final File parent = dest.getParentFile();
|
|
249
|
+
if (parent == null) {
|
|
250
|
+
throw new IOException("Destination has no parent: " + dest.getAbsolutePath());
|
|
251
|
+
}
|
|
252
|
+
if (!parent.exists() && !parent.mkdirs()) {
|
|
253
|
+
throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
final MessageDigest digest;
|
|
257
|
+
try {
|
|
258
|
+
digest = MessageDigest.getInstance("SHA-256");
|
|
259
|
+
} catch (java.security.NoSuchAlgorithmException e) {
|
|
260
|
+
throw new IOException("SHA-256 algorithm not available", e);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
final File tempFile = File.createTempFile("capgo_asset_", ".tmp", parent);
|
|
264
|
+
try {
|
|
265
|
+
try (FileOutputStream outStream = new FileOutputStream(tempFile)) {
|
|
266
|
+
final byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
|
|
267
|
+
int length;
|
|
268
|
+
while ((length = input.read(buffer)) != -1) {
|
|
269
|
+
digest.update(buffer, 0, length);
|
|
270
|
+
outStream.write(buffer, 0, length);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (!expectedHash.equalsIgnoreCase(CryptoCipher.digestToHex(digest))) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
return replaceFile(tempFile, dest);
|
|
277
|
+
} finally {
|
|
278
|
+
deleteQuietly(tempFile);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private static void deleteQuietly(final File file) {
|
|
283
|
+
if (file.exists() && !file.delete()) {
|
|
284
|
+
file.deleteOnExit();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
static boolean replaceFile(final File tempFile, final File dest) {
|
|
289
|
+
if (tempFile.renameTo(dest)) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
final File parent = dest.getParentFile();
|
|
293
|
+
if (parent == null) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
final File backup = new File(parent, ".capgo_bak_" + UUID.randomUUID());
|
|
297
|
+
deleteQuietly(backup);
|
|
298
|
+
if (dest.exists() && !dest.renameTo(backup)) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
if (!tempFile.renameTo(dest)) {
|
|
302
|
+
if (backup.exists()) {
|
|
303
|
+
backup.renameTo(dest);
|
|
304
|
+
}
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
deleteQuietly(backup);
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
static boolean rememberManifestTarget(final Set<String> seenTargets, final File targetFile) throws IOException {
|
|
312
|
+
return seenTargets.add(targetFile.getCanonicalPath());
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
static boolean tryCopyBuiltinAsset(final AssetManager assets, final String fileName, final File dest, final String expectedHash) {
|
|
316
|
+
if (assets == null || fileName == null || dest == null) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
final String assetPath = resolveBuiltinAssetPath(fileName);
|
|
321
|
+
try (InputStream in = assets.open(assetPath)) {
|
|
322
|
+
return copyStreamIfChecksumMatches(in, dest, expectedHash);
|
|
323
|
+
}
|
|
324
|
+
} catch (IOException e) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
static boolean builtinAssetMatches(final AssetManager assets, final String fileName, final String expectedHash) {
|
|
330
|
+
if (assets == null || fileName == null || expectedHash == null || expectedHash.isEmpty()) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
final String assetPath = resolveBuiltinAssetPath(fileName);
|
|
335
|
+
try (InputStream in = assets.open(assetPath)) {
|
|
336
|
+
return expectedHash.equalsIgnoreCase(CryptoCipher.calcChecksum(in));
|
|
337
|
+
}
|
|
338
|
+
} catch (IOException e) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private String getInputString(String key, String fallback) {
|
|
344
|
+
String value = getInputData().getString(key);
|
|
345
|
+
return value != null ? value : fallback;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
@NonNull
|
|
349
|
+
@Override
|
|
350
|
+
public Result doWork() {
|
|
351
|
+
try {
|
|
352
|
+
String url = getInputData().getString(URL);
|
|
353
|
+
String id = getInputData().getString(ID);
|
|
354
|
+
String documentsDir = getInputData().getString(DOCDIR);
|
|
355
|
+
String dest = getInputData().getString(FILEDEST);
|
|
356
|
+
String version = getInputData().getString(VERSION);
|
|
357
|
+
String sessionKey = getInputData().getString(SESSIONKEY);
|
|
358
|
+
String checksum = getInputData().getString(CHECKSUM);
|
|
359
|
+
String publicKey = getInputData().getString(PUBLIC_KEY);
|
|
360
|
+
boolean isManifest = getInputData().getBoolean(IS_MANIFEST, false);
|
|
361
|
+
|
|
362
|
+
logger.debug("doWork isManifest: " + isManifest);
|
|
363
|
+
|
|
364
|
+
if (isManifest) {
|
|
365
|
+
JSONArray manifest = DataManager.getInstance().getAndClearManifest(id);
|
|
366
|
+
if (manifest != null) {
|
|
367
|
+
handleManifestDownload(id, documentsDir, dest, version, sessionKey, publicKey, manifest);
|
|
368
|
+
return createSuccessResult(dest, version, sessionKey, checksum, true);
|
|
369
|
+
} else if (isStopped()) {
|
|
370
|
+
return createFailureResult("download_cancelled");
|
|
371
|
+
} else {
|
|
372
|
+
logger.error("Manifest is null");
|
|
373
|
+
return createFailureResult("Manifest is null");
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
handleSingleFileDownload(url, id, documentsDir, dest, version, sessionKey, checksum);
|
|
377
|
+
return createSuccessResult(dest, version, sessionKey, checksum, false);
|
|
378
|
+
}
|
|
379
|
+
} catch (Exception e) {
|
|
380
|
+
return createFailureResult(e.getMessage());
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private int calcTotalPercent(long downloadedBytes, long contentLength) {
|
|
385
|
+
if (contentLength <= 0) {
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
int percent = (int) (((double) downloadedBytes / contentLength) * 100);
|
|
389
|
+
percent = Math.max(10, percent);
|
|
390
|
+
percent = Math.min(70, percent);
|
|
391
|
+
return percent;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private void sendStatsAsync(String action, String version) {
|
|
395
|
+
try {
|
|
396
|
+
String statsUrl = getInputData().getString(STATS_URL);
|
|
397
|
+
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
JSONObject json = new JSONObject();
|
|
402
|
+
json.put("platform", "android");
|
|
403
|
+
json.put("app_id", getInputString(APP_ID, "unknown"));
|
|
404
|
+
json.put("plugin_version", getInputString(pluginVersion, "unknown"));
|
|
405
|
+
json.put("install_source", getInputString(INSTALL_SOURCE, ""));
|
|
406
|
+
json.put("version_name", version != null ? version : "");
|
|
407
|
+
json.put("old_version_name", "");
|
|
408
|
+
json.put("action", action);
|
|
409
|
+
json.put("device_id", getInputString(DEVICE_ID, ""));
|
|
410
|
+
json.put("custom_id", getInputString(CUSTOM_ID, ""));
|
|
411
|
+
json.put("version_build", getInputString(VERSION_BUILD, ""));
|
|
412
|
+
json.put("version_code", getInputString(VERSION_CODE, ""));
|
|
413
|
+
json.put("version_os", getInputString(VERSION_OS, currentVersionOs));
|
|
414
|
+
json.put("defaultChannel", getInputString(DEFAULT_CHANNEL, ""));
|
|
415
|
+
json.put("is_prod", getInputData().getBoolean(IS_PROD, true));
|
|
416
|
+
json.put("is_emulator", getInputData().getBoolean(IS_EMULATOR, false));
|
|
417
|
+
|
|
418
|
+
Request request = new Request.Builder()
|
|
419
|
+
.url(statsUrl)
|
|
420
|
+
.post(RequestBody.create(json.toString(), MediaType.get("application/json")))
|
|
421
|
+
.build();
|
|
422
|
+
|
|
423
|
+
sharedClient.newCall(request).enqueue(
|
|
424
|
+
new Callback() {
|
|
425
|
+
@Override
|
|
426
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
427
|
+
if (logger != null) {
|
|
428
|
+
logger.error("Failed to send stats: " + e.getMessage());
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
@Override
|
|
433
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) {
|
|
434
|
+
try (ResponseBody body = response.body()) {
|
|
435
|
+
// nothing else to do, just closing body
|
|
436
|
+
} catch (Exception ignored) {
|
|
437
|
+
} finally {
|
|
438
|
+
response.close();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
);
|
|
443
|
+
} catch (Exception e) {
|
|
444
|
+
if (logger != null) {
|
|
445
|
+
logger.error("sendStatsAsync error: " + e.getMessage());
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private void handleManifestDownload(
|
|
451
|
+
String id,
|
|
452
|
+
String documentsDir,
|
|
453
|
+
String dest,
|
|
454
|
+
String version,
|
|
455
|
+
String sessionKey,
|
|
456
|
+
String publicKey,
|
|
457
|
+
JSONArray manifest
|
|
458
|
+
) {
|
|
459
|
+
try {
|
|
460
|
+
logger.debug("handleManifestDownload");
|
|
461
|
+
|
|
462
|
+
// Send stats for manifest download start
|
|
463
|
+
sendStatsAsync("download_manifest_start", version);
|
|
464
|
+
|
|
465
|
+
File destFolder = new File(documentsDir, dest);
|
|
466
|
+
File cacheFolder = new File(getApplicationContext().getCacheDir(), "capgo_downloads");
|
|
467
|
+
File builtinFolder = new File(getApplicationContext().getFilesDir(), "public");
|
|
468
|
+
AssetManager assets = getApplicationContext().getAssets();
|
|
469
|
+
|
|
470
|
+
// Ensure directories are created
|
|
471
|
+
if (!destFolder.exists() && !destFolder.mkdirs()) {
|
|
472
|
+
throw new IOException("Failed to create destination directory: " + destFolder.getAbsolutePath());
|
|
473
|
+
}
|
|
474
|
+
cleanupOrphanedAssetTemps(destFolder);
|
|
475
|
+
if (!cacheFolder.exists() && !cacheFolder.mkdirs()) {
|
|
476
|
+
throw new IOException("Failed to create cache directory: " + cacheFolder.getAbsolutePath());
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
int totalFiles = manifest.length();
|
|
480
|
+
final AtomicLong completedFiles = new AtomicLong(0);
|
|
481
|
+
final AtomicBoolean hasError = new AtomicBoolean(false);
|
|
482
|
+
|
|
483
|
+
ExecutorService executor = Executors.newFixedThreadPool(Math.min(MANIFEST_MAX_CONCURRENT_FILES, Math.max(1, totalFiles)));
|
|
484
|
+
List<Future<?>> futures = new ArrayList<>();
|
|
485
|
+
final Set<String> seenTargets = new HashSet<>();
|
|
486
|
+
|
|
487
|
+
for (int i = 0; i < totalFiles; i++) {
|
|
488
|
+
JSONObject entry = manifest.getJSONObject(i);
|
|
489
|
+
String fileName = entry.getString("file_name");
|
|
490
|
+
String fileHash = entry.optString("file_hash", "");
|
|
491
|
+
String downloadUrl = entry.getString("download_url");
|
|
492
|
+
|
|
493
|
+
if (fileHash.isEmpty()) {
|
|
494
|
+
logger.error("Missing file_hash for manifest entry: " + fileName);
|
|
495
|
+
hasError.set(true);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
|
|
500
|
+
try {
|
|
501
|
+
fileHash = CryptoCipher.decryptChecksum(fileHash, publicKey);
|
|
502
|
+
} catch (Exception e) {
|
|
503
|
+
logger.error("Error decrypting checksum for " + fileName + "fileHash: " + fileHash);
|
|
504
|
+
hasError.set(true);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
final String finalFileHash = fileHash;
|
|
510
|
+
|
|
511
|
+
// Check if file is a Brotli file and remove .br extension from target
|
|
512
|
+
boolean isBrotli = fileName.endsWith(".br");
|
|
513
|
+
String targetFileName = isBrotli ? fileName.substring(0, fileName.length() - 3) : fileName;
|
|
514
|
+
|
|
515
|
+
File targetFile;
|
|
516
|
+
File builtinFile;
|
|
517
|
+
try {
|
|
518
|
+
targetFile = resolveManifestTargetFile(destFolder, fileName);
|
|
519
|
+
builtinFile = resolveManifestBuiltinFile(builtinFolder, fileName);
|
|
520
|
+
if (!rememberManifestTarget(seenTargets, targetFile)) {
|
|
521
|
+
logger.error("Duplicate manifest target path: " + fileName);
|
|
522
|
+
sendStatsAsync("manifest_path_fail", version + ":" + fileName);
|
|
523
|
+
hasError.set(true);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
} catch (IOException e) {
|
|
527
|
+
logger.error("Invalid manifest file path: " + fileName);
|
|
528
|
+
sendStatsAsync("manifest_path_fail", version + ":" + fileName);
|
|
529
|
+
hasError.set(true);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
String cacheBaseName = new File(isBrotli ? targetFileName : fileName).getName();
|
|
533
|
+
final File cacheFile = CapgoUpdater.isSafeCacheHash(finalFileHash)
|
|
534
|
+
? new File(cacheFolder, finalFileHash + "_" + cacheBaseName)
|
|
535
|
+
: null;
|
|
536
|
+
final File legacyCacheFile =
|
|
537
|
+
isBrotli && cacheFile != null ? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName()) : null;
|
|
538
|
+
|
|
539
|
+
// Ensure parent directories of the target file exist
|
|
540
|
+
if (!Objects.requireNonNull(targetFile.getParentFile()).exists() && !targetFile.getParentFile().mkdirs()) {
|
|
541
|
+
logger.error("Failed to create parent directory for: " + targetFile.getAbsolutePath());
|
|
542
|
+
hasError.set(true);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
final boolean finalIsBrotli = isBrotli;
|
|
547
|
+
Future<?> future = executor.submit(() -> {
|
|
548
|
+
try {
|
|
549
|
+
if (tryCopyBuiltinAsset(assets, fileName, targetFile, finalFileHash)) {
|
|
550
|
+
logger.debug("using builtin asset " + fileName);
|
|
551
|
+
} else if (tryCopyBuiltinFile(builtinFile, targetFile, finalFileHash)) {
|
|
552
|
+
logger.debug("using builtin file " + fileName);
|
|
553
|
+
} else if (
|
|
554
|
+
tryCopyFromCache(cacheFile, targetFile, finalFileHash) ||
|
|
555
|
+
(legacyCacheFile != null && tryCopyFromCache(legacyCacheFile, targetFile, finalFileHash))
|
|
556
|
+
) {
|
|
557
|
+
logger.debug("already cached " + fileName);
|
|
558
|
+
} else {
|
|
559
|
+
downloadAndVerify(
|
|
560
|
+
downloadUrl,
|
|
561
|
+
targetFile,
|
|
562
|
+
cacheFile,
|
|
563
|
+
finalFileHash,
|
|
564
|
+
sessionKey,
|
|
565
|
+
publicKey,
|
|
566
|
+
finalIsBrotli,
|
|
567
|
+
fileName
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
long completed = completedFiles.incrementAndGet();
|
|
572
|
+
int percent = calcTotalPercent(completed, totalFiles);
|
|
573
|
+
setProgress(percent);
|
|
574
|
+
} catch (Exception e) {
|
|
575
|
+
logger.error("Error processing file: " + fileName + " " + e.getMessage());
|
|
576
|
+
sendStatsAsync("download_manifest_file_fail", version + ":" + fileName);
|
|
577
|
+
hasError.set(true);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
futures.add(future);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Wait for all downloads to complete
|
|
584
|
+
for (Future<?> future : futures) {
|
|
585
|
+
try {
|
|
586
|
+
future.get();
|
|
587
|
+
} catch (Exception e) {
|
|
588
|
+
logger.error("Error waiting for download " + e.getMessage());
|
|
589
|
+
hasError.set(true);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
executor.shutdown();
|
|
594
|
+
try {
|
|
595
|
+
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
|
|
596
|
+
executor.shutdownNow();
|
|
597
|
+
}
|
|
598
|
+
} catch (InterruptedException e) {
|
|
599
|
+
executor.shutdownNow();
|
|
600
|
+
Thread.currentThread().interrupt();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (hasError.get()) {
|
|
604
|
+
logger.error("One or more files failed to download");
|
|
605
|
+
throw new IOException("One or more files failed to download");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Send stats for manifest download complete
|
|
609
|
+
sendStatsAsync("download_manifest_complete", version);
|
|
610
|
+
} catch (Exception e) {
|
|
611
|
+
logger.error("Error in handleManifestDownload " + e.getMessage());
|
|
612
|
+
throw new RuntimeException(e.getLocalizedMessage());
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private void handleSingleFileDownload(
|
|
617
|
+
String url,
|
|
618
|
+
String id,
|
|
619
|
+
String documentsDir,
|
|
620
|
+
String dest,
|
|
621
|
+
String version,
|
|
622
|
+
String sessionKey,
|
|
623
|
+
String checksum
|
|
624
|
+
) {
|
|
625
|
+
// Send stats for zip download start
|
|
626
|
+
sendStatsAsync("download_zip_start", version);
|
|
627
|
+
|
|
628
|
+
File target = new File(documentsDir, dest);
|
|
629
|
+
// Use bundle ID in temp file names to prevent collisions when multiple downloads run concurrently
|
|
630
|
+
File infoFile = new File(documentsDir, "update_" + id + ".dat");
|
|
631
|
+
File tempFile = new File(documentsDir, "temp_" + id + ".tmp");
|
|
632
|
+
|
|
633
|
+
// Check available disk space before starting
|
|
634
|
+
long availableSpace = target.getParentFile().getUsableSpace();
|
|
635
|
+
long estimatedSize = 50 * 1024 * 1024; // 50MB default estimate
|
|
636
|
+
if (availableSpace < estimatedSize * 2) {
|
|
637
|
+
throw new RuntimeException("insufficient_disk_space");
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
HttpURLConnection httpConn = null;
|
|
641
|
+
InputStream inputStream = null;
|
|
642
|
+
FileOutputStream outputStream = null;
|
|
643
|
+
BufferedReader reader = null;
|
|
644
|
+
BufferedWriter writer = null;
|
|
645
|
+
|
|
646
|
+
try {
|
|
647
|
+
URL u = new URL(url);
|
|
648
|
+
httpConn = (HttpURLConnection) u.openConnection();
|
|
649
|
+
|
|
650
|
+
// Zip can stall longer than a JSON API call; keep a floor so
|
|
651
|
+
// responseTimeout cannot shrink large-bundle downloads.
|
|
652
|
+
int zipTimeoutMs = Math.max(httpTimeoutMs, 60_000);
|
|
653
|
+
httpConn.setConnectTimeout(zipTimeoutMs);
|
|
654
|
+
httpConn.setReadTimeout(zipTimeoutMs);
|
|
655
|
+
|
|
656
|
+
// Reading progress file (if exist)
|
|
657
|
+
long downloadedBytes = 0;
|
|
658
|
+
|
|
659
|
+
if (infoFile.exists() && tempFile.exists()) {
|
|
660
|
+
try {
|
|
661
|
+
reader = new BufferedReader(new FileReader(infoFile));
|
|
662
|
+
String updateVersion = reader.readLine();
|
|
663
|
+
if (updateVersion != null && !updateVersion.equals(version)) {
|
|
664
|
+
clearDownloadData(documentsDir, id);
|
|
665
|
+
} else {
|
|
666
|
+
downloadedBytes = tempFile.length();
|
|
667
|
+
}
|
|
668
|
+
} finally {
|
|
669
|
+
if (reader != null) {
|
|
670
|
+
try {
|
|
671
|
+
reader.close();
|
|
672
|
+
} catch (Exception ignored) {}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
} else {
|
|
676
|
+
clearDownloadData(documentsDir, id);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (downloadedBytes > 0) {
|
|
680
|
+
httpConn.setRequestProperty("Range", "bytes=" + downloadedBytes + "-");
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
int responseCode = httpConn.getResponseCode();
|
|
684
|
+
|
|
685
|
+
if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_PARTIAL) {
|
|
686
|
+
long contentLength = httpConn.getContentLength() + downloadedBytes;
|
|
687
|
+
|
|
688
|
+
// Check if we have enough space for the actual file
|
|
689
|
+
if (contentLength > 0 && availableSpace < contentLength * 2) {
|
|
690
|
+
throw new RuntimeException("insufficient_disk_space");
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
try {
|
|
694
|
+
inputStream = httpConn.getInputStream();
|
|
695
|
+
outputStream = new FileOutputStream(tempFile, downloadedBytes > 0);
|
|
696
|
+
|
|
697
|
+
if (downloadedBytes == 0) {
|
|
698
|
+
writer = new BufferedWriter(new FileWriter(infoFile));
|
|
699
|
+
writer.write(String.valueOf(version));
|
|
700
|
+
writer.close();
|
|
701
|
+
writer = null;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
|
|
705
|
+
int lastNotifiedPercent = 0;
|
|
706
|
+
int bytesRead;
|
|
707
|
+
|
|
708
|
+
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
|
709
|
+
outputStream.write(buffer, 0, bytesRead);
|
|
710
|
+
downloadedBytes += bytesRead;
|
|
711
|
+
|
|
712
|
+
// Flush every 1MB to ensure progress is saved
|
|
713
|
+
if (downloadedBytes % (1024 * 1024) == 0) {
|
|
714
|
+
outputStream.flush();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Computing percentage
|
|
718
|
+
int percent = calcTotalPercent(downloadedBytes, contentLength);
|
|
719
|
+
if (percent >= lastNotifiedPercent + 10) {
|
|
720
|
+
lastNotifiedPercent = (percent / 10) * 10;
|
|
721
|
+
setProgress(lastNotifiedPercent);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Final flush
|
|
726
|
+
outputStream.flush();
|
|
727
|
+
outputStream.close();
|
|
728
|
+
outputStream = null;
|
|
729
|
+
|
|
730
|
+
inputStream.close();
|
|
731
|
+
inputStream = null;
|
|
732
|
+
|
|
733
|
+
// Rename the temp file with the final name (dest)
|
|
734
|
+
if (!tempFile.renameTo(new File(documentsDir, dest))) {
|
|
735
|
+
throw new RuntimeException("Failed to rename temp file to final destination");
|
|
736
|
+
}
|
|
737
|
+
infoFile.delete();
|
|
738
|
+
|
|
739
|
+
// Send stats for zip download complete
|
|
740
|
+
sendStatsAsync("download_zip_complete", version);
|
|
741
|
+
} catch (OutOfMemoryError e) {
|
|
742
|
+
logger.error("Out of memory during download: " + e.getMessage());
|
|
743
|
+
// Try to free some memory
|
|
744
|
+
System.gc();
|
|
745
|
+
throw new RuntimeException("low_mem_fail");
|
|
746
|
+
} finally {
|
|
747
|
+
// Ensure all resources are closed
|
|
748
|
+
if (outputStream != null) {
|
|
749
|
+
try {
|
|
750
|
+
outputStream.close();
|
|
751
|
+
} catch (Exception ignored) {}
|
|
752
|
+
}
|
|
753
|
+
if (inputStream != null) {
|
|
754
|
+
try {
|
|
755
|
+
inputStream.close();
|
|
756
|
+
} catch (Exception ignored) {}
|
|
757
|
+
}
|
|
758
|
+
if (writer != null) {
|
|
759
|
+
try {
|
|
760
|
+
writer.close();
|
|
761
|
+
} catch (Exception ignored) {}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
} else {
|
|
765
|
+
infoFile.delete();
|
|
766
|
+
throw new RuntimeException("HTTP error: " + responseCode);
|
|
767
|
+
}
|
|
768
|
+
} catch (OutOfMemoryError e) {
|
|
769
|
+
logger.error("Critical memory error: " + e.getMessage());
|
|
770
|
+
System.gc(); // Suggest garbage collection
|
|
771
|
+
throw new RuntimeException("low_mem_fail");
|
|
772
|
+
} catch (SecurityException e) {
|
|
773
|
+
logger.error("Security error during download: " + e.getMessage());
|
|
774
|
+
throw new RuntimeException("security_error: " + e.getMessage());
|
|
775
|
+
} catch (Exception e) {
|
|
776
|
+
logger.error("Download error: " + e.getMessage());
|
|
777
|
+
throw new RuntimeException(e.getMessage());
|
|
778
|
+
} finally {
|
|
779
|
+
// Ensure connection is closed
|
|
780
|
+
if (httpConn != null) {
|
|
781
|
+
try {
|
|
782
|
+
httpConn.disconnect();
|
|
783
|
+
} catch (Exception ignored) {}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private void clearDownloadData(String docDir, String id) {
|
|
789
|
+
File tempFile = new File(docDir, "temp_" + id + ".tmp");
|
|
790
|
+
File infoFile = new File(docDir, "update_" + id + ".dat");
|
|
791
|
+
try {
|
|
792
|
+
tempFile.delete();
|
|
793
|
+
infoFile.delete();
|
|
794
|
+
infoFile.createNewFile();
|
|
795
|
+
tempFile.createNewFile();
|
|
796
|
+
} catch (IOException e) {
|
|
797
|
+
logger.error("Error in clearDownloadData " + e.getMessage());
|
|
798
|
+
// not a fatal error, so we don't throw an exception
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Helper methods
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed.
|
|
806
|
+
* This handles the race condition where OS can delete cache files between exists() check and copy.
|
|
807
|
+
*/
|
|
808
|
+
private boolean tryCopyFromCache(File source, File dest, String expectedHash) {
|
|
809
|
+
// First quick check - if file doesn't exist or was truncated, don't bother
|
|
810
|
+
if (!CapgoUpdater.isReusableCacheFile(source, expectedHash)) {
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Hash is in the cache file name and was verified when written.
|
|
815
|
+
// Re-hashing here would re-read every reused file on low-RAM devices.
|
|
816
|
+
try {
|
|
817
|
+
copyFile(source, dest);
|
|
818
|
+
return true;
|
|
819
|
+
} catch (IOException e) {
|
|
820
|
+
// File was deleted between check and copy, or other IO error - caller should download instead
|
|
821
|
+
logger.debug("Cache copy failed (likely OS eviction): " + e.getMessage());
|
|
822
|
+
return false;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private void copyFile(File source, File dest) throws IOException {
|
|
827
|
+
copyFileChannel(source, dest);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
static void copyFileChannel(final File source, final File dest) throws IOException {
|
|
831
|
+
final File parent = dest.getParentFile();
|
|
832
|
+
if (parent != null && !parent.exists() && !parent.mkdirs()) {
|
|
833
|
+
throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
final File tempFile = File.createTempFile("capgo-", ".tmp", parent);
|
|
837
|
+
try {
|
|
838
|
+
try (
|
|
839
|
+
FileInputStream inStream = new FileInputStream(source);
|
|
840
|
+
FileOutputStream outStream = new FileOutputStream(tempFile);
|
|
841
|
+
FileChannel inChannel = inStream.getChannel();
|
|
842
|
+
FileChannel outChannel = outStream.getChannel()
|
|
843
|
+
) {
|
|
844
|
+
long size = inChannel.size();
|
|
845
|
+
long pos = 0;
|
|
846
|
+
while (pos < size) {
|
|
847
|
+
long transferred = inChannel.transferTo(pos, size - pos, outChannel);
|
|
848
|
+
if (transferred <= 0) {
|
|
849
|
+
throw new IOException("Failed to copy file: " + source.getAbsolutePath());
|
|
850
|
+
}
|
|
851
|
+
pos += transferred;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
CryptoCipher.replaceFile(tempFile, dest);
|
|
855
|
+
} finally {
|
|
856
|
+
if (tempFile.exists()) {
|
|
857
|
+
tempFile.delete();
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private void downloadAndVerify(
|
|
863
|
+
String downloadUrl,
|
|
864
|
+
File targetFile,
|
|
865
|
+
File cacheFile,
|
|
866
|
+
String expectedHash,
|
|
867
|
+
String sessionKey,
|
|
868
|
+
String publicKey,
|
|
869
|
+
boolean isBrotli,
|
|
870
|
+
String relativeName
|
|
871
|
+
) throws Exception {
|
|
872
|
+
logger.debug("downloadAndVerify " + downloadUrl);
|
|
873
|
+
|
|
874
|
+
File finalTargetFile = targetFile;
|
|
875
|
+
File cacheFolder = new File(getApplicationContext().getCacheDir(), "capgo_downloads");
|
|
876
|
+
if (!cacheFolder.exists() && !cacheFolder.mkdirs()) {
|
|
877
|
+
throw new IOException("Failed to create cache directory: " + cacheFolder.getAbsolutePath());
|
|
878
|
+
}
|
|
879
|
+
File partial = manifestPartialFile(cacheFolder, expectedHash, relativeName);
|
|
880
|
+
File workFile = null;
|
|
881
|
+
boolean keepPartial = partial.isFile();
|
|
882
|
+
try {
|
|
883
|
+
long existing = partial.isFile() ? partial.length() : 0;
|
|
884
|
+
Request.Builder builder = new Request.Builder().url(downloadUrl);
|
|
885
|
+
if (existing > 0) {
|
|
886
|
+
builder.header("Range", "bytes=" + existing + "-");
|
|
887
|
+
}
|
|
888
|
+
try (Response response = sharedClient.newCall(builder.build()).execute()) {
|
|
889
|
+
int code = response.code();
|
|
890
|
+
if (code == 416 && existing > 0) {
|
|
891
|
+
logger.debug("Range not satisfiable, using existing partial " + partial.getName());
|
|
892
|
+
keepPartial = true;
|
|
893
|
+
} else if (code != HttpURLConnection.HTTP_OK && code != HttpURLConnection.HTTP_PARTIAL) {
|
|
894
|
+
sendStatsAsync("download_manifest_file_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
|
|
895
|
+
throw new IOException("Unexpected response code: " + code);
|
|
896
|
+
} else {
|
|
897
|
+
ResponseBody responseBody = response.body();
|
|
898
|
+
if (responseBody == null) {
|
|
899
|
+
throw new IOException("Response body is null");
|
|
900
|
+
}
|
|
901
|
+
try {
|
|
902
|
+
long resumeFrom = existing;
|
|
903
|
+
if (code == HttpURLConnection.HTTP_PARTIAL && existing > 0) {
|
|
904
|
+
String contentRange = response.header("Content-Range");
|
|
905
|
+
if (contentRange == null || !contentRange.startsWith("bytes " + existing + "-")) {
|
|
906
|
+
logger.debug("Unexpected Content-Range, restarting download of " + partial.getName());
|
|
907
|
+
resumeFrom = 0;
|
|
908
|
+
if (partial.exists() && !partial.delete()) {
|
|
909
|
+
logger.debug("Failed to delete partial before restart " + partial.getName());
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
writeHttpBody(partial, responseBody.byteStream(), code, resumeFrom);
|
|
914
|
+
keepPartial = true;
|
|
915
|
+
} catch (Exception e) {
|
|
916
|
+
keepPartial = true;
|
|
917
|
+
throw e;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
boolean needDecrypt = publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty();
|
|
923
|
+
File source = partial;
|
|
924
|
+
if (needDecrypt) {
|
|
925
|
+
workFile = new File(cacheFolder, "work_" + UUID.randomUUID() + "_" + targetFile.getName() + ".tmp");
|
|
926
|
+
copyFile(partial, workFile);
|
|
927
|
+
try {
|
|
928
|
+
logger.debug("Decrypting file " + targetFile.getName());
|
|
929
|
+
CryptoCipher.decryptFile(workFile, publicKey, sessionKey);
|
|
930
|
+
source = workFile;
|
|
931
|
+
} catch (Exception e) {
|
|
932
|
+
keepPartial = false;
|
|
933
|
+
throw e;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
try {
|
|
938
|
+
if (isBrotli) {
|
|
939
|
+
decompressBrotli(source, finalTargetFile, targetFile.getName(), expectedHash);
|
|
940
|
+
} else {
|
|
941
|
+
try (FileInputStream fis = new FileInputStream(source)) {
|
|
942
|
+
writeFileAtomic(finalTargetFile, fis, expectedHash);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
} catch (IOException e) {
|
|
946
|
+
String msg = e.getMessage();
|
|
947
|
+
if (msg != null && msg.contains("Checksum verification failed")) {
|
|
948
|
+
sendStatsAsync("download_manifest_checksum_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
|
|
949
|
+
keepPartial = false;
|
|
950
|
+
} else if (isBrotli && msg != null && msg.toLowerCase(java.util.Locale.US).contains("brotli")) {
|
|
951
|
+
sendStatsAsync("download_manifest_brotli_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
|
|
952
|
+
keepPartial = false;
|
|
953
|
+
}
|
|
954
|
+
throw e;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
CryptoCipher.logChecksumInfo("Expected checksum", expectedHash);
|
|
958
|
+
|
|
959
|
+
if (cacheFile != null) {
|
|
960
|
+
try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
|
|
961
|
+
writeFileAtomic(cacheFile, fis, null);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
keepPartial = false;
|
|
965
|
+
} catch (Exception e) {
|
|
966
|
+
throw new IOException("Error in downloadAndVerify: " + e.getMessage(), e);
|
|
967
|
+
} finally {
|
|
968
|
+
if (workFile != null && workFile.exists() && !workFile.delete()) {
|
|
969
|
+
logger.debug("Failed to delete decrypt work file");
|
|
970
|
+
}
|
|
971
|
+
if (!keepPartial && partial.exists() && !partial.delete()) {
|
|
972
|
+
logger.debug("Failed to delete manifest partial " + partial.getName());
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
static String safePartialToken(String fileName) {
|
|
978
|
+
return CryptoCipher.shortPathKey(fileName);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
static File manifestPartialFile(File cacheDir, String hash, String fileName) {
|
|
982
|
+
String token = safePartialToken(fileName);
|
|
983
|
+
if (CapgoUpdater.isSafeCacheHash(hash) && hash.length() == 64) {
|
|
984
|
+
return new File(cacheDir, "partial_" + hash + "_" + token + ".tmp");
|
|
985
|
+
}
|
|
986
|
+
String digest = CryptoCipher.shortPathKey((hash == null ? "" : hash) + "\0" + (fileName == null ? "" : fileName));
|
|
987
|
+
return new File(cacheDir, "partial_" + digest + "_" + token + ".tmp");
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
static boolean shouldAppendHttpBody(int statusCode, long existingBytes) {
|
|
991
|
+
return existingBytes > 0 && statusCode == HttpURLConnection.HTTP_PARTIAL;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
static void writeHttpBody(File dest, InputStream body, int statusCode, long existingBytes) throws IOException {
|
|
995
|
+
boolean append = shouldAppendHttpBody(statusCode, existingBytes);
|
|
996
|
+
byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
|
|
997
|
+
try (FileOutputStream fos = new FileOutputStream(dest, append)) {
|
|
998
|
+
int n;
|
|
999
|
+
long written = append ? existingBytes : 0;
|
|
1000
|
+
while ((n = body.read(buffer)) != -1) {
|
|
1001
|
+
fos.write(buffer, 0, n);
|
|
1002
|
+
written += n;
|
|
1003
|
+
if (written % (1024 * 1024) == 0) {
|
|
1004
|
+
fos.flush();
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
fos.flush();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
static boolean tryCopyBuiltinFile(final File builtinFile, final File dest, final String expectedHash) {
|
|
1012
|
+
if (builtinFile == null || dest == null || expectedHash == null || expectedHash.isEmpty() || !builtinFile.isFile()) {
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
try {
|
|
1016
|
+
if (!expectedHash.equalsIgnoreCase(CryptoCipher.calcChecksum(builtinFile))) {
|
|
1017
|
+
return false;
|
|
1018
|
+
}
|
|
1019
|
+
copyFileChannel(builtinFile, dest);
|
|
1020
|
+
return true;
|
|
1021
|
+
} catch (IOException e) {
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
static void decompressBrotli(File input, File output, String fileName) throws IOException {
|
|
1027
|
+
decompressBrotli(input, output, fileName, null);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
static void decompressBrotli(File input, File output, String fileName, String expectedChecksum) throws IOException {
|
|
1031
|
+
File parent = output.getParentFile();
|
|
1032
|
+
if (parent != null) {
|
|
1033
|
+
parent.mkdirs();
|
|
1034
|
+
}
|
|
1035
|
+
long length = input.length();
|
|
1036
|
+
if (length == 0) {
|
|
1037
|
+
writeFileAtomic(output, new ByteArrayInputStream(new byte[0]), expectedChecksum);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
byte[] head = new byte[(int) Math.min(3, length)];
|
|
1042
|
+
byte last = 0;
|
|
1043
|
+
try (RandomAccessFile raf = new RandomAccessFile(input, "r")) {
|
|
1044
|
+
raf.readFully(head);
|
|
1045
|
+
if (length >= 1) {
|
|
1046
|
+
raf.seek(length - 1);
|
|
1047
|
+
last = raf.readByte();
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (length == 3 && head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06) {
|
|
1052
|
+
writeFileAtomic(output, new ByteArrayInputStream(new byte[0]), expectedChecksum);
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (length > 3 && last == 0x03) {
|
|
1057
|
+
boolean emptyWrapper = head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06;
|
|
1058
|
+
boolean qualityZeroWrapper = head[0] == 0x0b && head[1] == 0x02 && head[2] == (byte) 0x80;
|
|
1059
|
+
if (emptyWrapper || qualityZeroWrapper) {
|
|
1060
|
+
try (FileInputStream fis = new FileInputStream(input)) {
|
|
1061
|
+
long skipped = 0;
|
|
1062
|
+
while (skipped < 3) {
|
|
1063
|
+
long n = fis.skip(3 - skipped);
|
|
1064
|
+
if (n <= 0) {
|
|
1065
|
+
break;
|
|
1066
|
+
}
|
|
1067
|
+
skipped += n;
|
|
1068
|
+
}
|
|
1069
|
+
writeFileAtomic(output, new BoundedInputStream(fis, length - 4), expectedChecksum);
|
|
1070
|
+
}
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
try (FileInputStream fis = new FileInputStream(input); BrotliInputStream brotliInputStream = new BrotliInputStream(fis)) {
|
|
1076
|
+
writeFileAtomic(output, brotliInputStream, expectedChecksum);
|
|
1077
|
+
} catch (IOException e) {
|
|
1078
|
+
logger.error("Error: Brotli process failed for " + fileName + ". Status: " + e.getMessage());
|
|
1079
|
+
StringBuilder hexDump = new StringBuilder();
|
|
1080
|
+
try (FileInputStream peek = new FileInputStream(input)) {
|
|
1081
|
+
byte[] prefix = new byte[(int) Math.min(32, length)];
|
|
1082
|
+
int n = peek.read(prefix);
|
|
1083
|
+
for (int i = 0; i < n; i++) {
|
|
1084
|
+
hexDump.append(String.format("%02x ", prefix[i]));
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
logger.error("Error: Raw data (" + fileName + "): " + hexDump);
|
|
1088
|
+
throw new IOException("Brotli process failed for " + fileName + ": " + e.getMessage(), e);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
private static final class BoundedInputStream extends FilterInputStream {
|
|
1093
|
+
|
|
1094
|
+
private long remaining;
|
|
1095
|
+
|
|
1096
|
+
BoundedInputStream(InputStream in, long remaining) {
|
|
1097
|
+
super(in);
|
|
1098
|
+
this.remaining = remaining;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
@Override
|
|
1102
|
+
public int read() throws IOException {
|
|
1103
|
+
if (remaining <= 0) {
|
|
1104
|
+
return -1;
|
|
1105
|
+
}
|
|
1106
|
+
int value = super.read();
|
|
1107
|
+
if (value >= 0) {
|
|
1108
|
+
remaining--;
|
|
1109
|
+
}
|
|
1110
|
+
return value;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
@Override
|
|
1114
|
+
public int read(byte[] b, int off, int len) throws IOException {
|
|
1115
|
+
if (remaining <= 0) {
|
|
1116
|
+
return -1;
|
|
1117
|
+
}
|
|
1118
|
+
int capped = (int) Math.min(len, remaining);
|
|
1119
|
+
int n = super.read(b, off, capped);
|
|
1120
|
+
if (n > 0) {
|
|
1121
|
+
remaining -= n;
|
|
1122
|
+
}
|
|
1123
|
+
return n;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Atomically write a stream to a file using the 256 KiB IO buffer.
|
|
1129
|
+
* When expectedChecksum is set, SHA-256 is hashed during the write.
|
|
1130
|
+
*/
|
|
1131
|
+
static void writeFileAtomic(File targetFile, InputStream inputStream, String expectedChecksum) throws IOException {
|
|
1132
|
+
File tempFile = File.createTempFile("capgo-", ".tmp", targetFile.getParentFile());
|
|
1133
|
+
|
|
1134
|
+
try {
|
|
1135
|
+
// Okio's default segment is 8 KiB. Copy with 256 KiB so 8 MiB wrapper unwraps
|
|
1136
|
+
// are not 1000 tiny writes.
|
|
1137
|
+
byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
|
|
1138
|
+
MessageDigest digest = null;
|
|
1139
|
+
if (expectedChecksum != null && !expectedChecksum.isEmpty()) {
|
|
1140
|
+
try {
|
|
1141
|
+
digest = MessageDigest.getInstance("SHA-256");
|
|
1142
|
+
} catch (java.security.NoSuchAlgorithmException e) {
|
|
1143
|
+
throw new IOException("SHA-256 algorithm not available", e);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
try (FileOutputStream fos = new FileOutputStream(tempFile)) {
|
|
1147
|
+
int n;
|
|
1148
|
+
while ((n = inputStream.read(buffer)) != -1) {
|
|
1149
|
+
if (digest != null) {
|
|
1150
|
+
digest.update(buffer, 0, n);
|
|
1151
|
+
}
|
|
1152
|
+
fos.write(buffer, 0, n);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
if (digest != null) {
|
|
1157
|
+
String actualChecksum = CryptoCipher.digestToHex(digest);
|
|
1158
|
+
if (!expectedChecksum.equalsIgnoreCase(actualChecksum)) {
|
|
1159
|
+
throw new IOException("Checksum verification failed expected: " + expectedChecksum + " calculated: " + actualChecksum);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Atomic rename (on same filesystem). renameTo works on API 24; Files.move does not.
|
|
1164
|
+
CryptoCipher.replaceFile(tempFile, targetFile);
|
|
1165
|
+
} catch (IOException e) {
|
|
1166
|
+
if (tempFile.exists()) {
|
|
1167
|
+
tempFile.delete();
|
|
1168
|
+
}
|
|
1169
|
+
throw e;
|
|
1170
|
+
} catch (Exception e) {
|
|
1171
|
+
if (tempFile.exists()) {
|
|
1172
|
+
tempFile.delete();
|
|
1173
|
+
}
|
|
1174
|
+
throw new IOException("Failed to write file atomically: " + e.getMessage(), e);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Clean up old temporary files (both .tmp and update_*.dat files)
|
|
1180
|
+
*/
|
|
1181
|
+
private void cleanupOldTempFiles(File directory) {
|
|
1182
|
+
if (directory == null || !directory.exists()) return;
|
|
1183
|
+
|
|
1184
|
+
File[] tempFiles = directory.listFiles(
|
|
1185
|
+
(dir, name) -> name.endsWith(".tmp") || (name.startsWith("update_") && name.endsWith(".dat"))
|
|
1186
|
+
);
|
|
1187
|
+
if (tempFiles != null) {
|
|
1188
|
+
long oneHourAgo = System.currentTimeMillis() - 3600000;
|
|
1189
|
+
for (File tempFile : tempFiles) {
|
|
1190
|
+
if (tempFile.lastModified() < oneHourAgo) {
|
|
1191
|
+
tempFile.delete();
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
private void cleanupOrphanedAssetTemps(final File directory) {
|
|
1198
|
+
if (directory == null || !directory.isDirectory()) {
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
final File[] children = directory.listFiles();
|
|
1202
|
+
if (children == null) {
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
final long oneHourAgo = System.currentTimeMillis() - 3600000;
|
|
1206
|
+
for (final File child : children) {
|
|
1207
|
+
if (child.isDirectory()) {
|
|
1208
|
+
cleanupOrphanedAssetTemps(child);
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
final String name = child.getName();
|
|
1212
|
+
final boolean orphanedAssetTemp = name.startsWith("capgo_asset_") && name.endsWith(".tmp");
|
|
1213
|
+
final boolean orphanedBackup = name.startsWith(".capgo_bak_");
|
|
1214
|
+
if ((orphanedAssetTemp || orphanedBackup) && child.lastModified() < oneHourAgo) {
|
|
1215
|
+
child.delete();
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|