@capgo/capacitor-updater 8.0.0 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CapgoCapacitorUpdater.podspec +7 -5
- package/Package.swift +37 -0
- package/README.md +1461 -231
- package/android/build.gradle +29 -12
- package/android/proguard-rules.pro +45 -0
- package/android/src/main/AndroidManifest.xml +0 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +223 -195
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +23 -23
- package/android/src/main/java/ee/forgr/capacitor_updater/Callback.java +13 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +2159 -1234
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1507 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +330 -121
- package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +28 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +43 -49
- package/android/src/main/java/ee/forgr/capacitor_updater/DelayUntilNext.java +4 -4
- 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 +808 -117
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +156 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +32 -0
- 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 +2187 -625
- package/dist/esm/definitions.d.ts +1286 -249
- 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 +3 -2
- package/dist/esm/index.js +5 -4
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web.d.ts +36 -41
- package/dist/esm/web.js +94 -35
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +376 -35
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +376 -35
- 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/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1526 -0
- 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/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +311 -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 +41 -35
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdater.java +0 -1130
- package/ios/Plugin/CapacitorUpdater.swift +0 -858
- package/ios/Plugin/CapacitorUpdaterPlugin.h +0 -10
- package/ios/Plugin/CapacitorUpdaterPlugin.m +0 -27
- package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -675
- package/ios/Plugin/CryptoCipher.swift +0 -240
- /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
|
@@ -0,0 +1,1507 @@
|
|
|
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
|
+
|
|
7
|
+
package ee.forgr.capacitor_updater;
|
|
8
|
+
|
|
9
|
+
import android.app.Activity;
|
|
10
|
+
import android.content.Context;
|
|
11
|
+
import android.content.SharedPreferences;
|
|
12
|
+
import android.os.Build;
|
|
13
|
+
import androidx.annotation.NonNull;
|
|
14
|
+
import androidx.lifecycle.LifecycleOwner;
|
|
15
|
+
import androidx.work.Data;
|
|
16
|
+
import androidx.work.WorkInfo;
|
|
17
|
+
import androidx.work.WorkManager;
|
|
18
|
+
import com.google.common.util.concurrent.Futures;
|
|
19
|
+
import com.google.common.util.concurrent.ListenableFuture;
|
|
20
|
+
import java.io.BufferedInputStream;
|
|
21
|
+
import java.io.File;
|
|
22
|
+
import java.io.FileInputStream;
|
|
23
|
+
import java.io.FileNotFoundException;
|
|
24
|
+
import java.io.FileOutputStream;
|
|
25
|
+
import java.io.FilenameFilter;
|
|
26
|
+
import java.io.IOException;
|
|
27
|
+
import java.security.SecureRandom;
|
|
28
|
+
import java.util.ArrayList;
|
|
29
|
+
import java.util.Date;
|
|
30
|
+
import java.util.HashMap;
|
|
31
|
+
import java.util.Iterator;
|
|
32
|
+
import java.util.List;
|
|
33
|
+
import java.util.Map;
|
|
34
|
+
import java.util.Objects;
|
|
35
|
+
import java.util.Set;
|
|
36
|
+
import java.util.concurrent.CompletableFuture;
|
|
37
|
+
import java.util.concurrent.ConcurrentHashMap;
|
|
38
|
+
import java.util.concurrent.ExecutorService;
|
|
39
|
+
import java.util.concurrent.Executors;
|
|
40
|
+
import java.util.concurrent.TimeUnit;
|
|
41
|
+
import java.util.zip.ZipEntry;
|
|
42
|
+
import java.util.zip.ZipInputStream;
|
|
43
|
+
import okhttp3.*;
|
|
44
|
+
import okhttp3.HttpUrl;
|
|
45
|
+
import org.json.JSONArray;
|
|
46
|
+
import org.json.JSONException;
|
|
47
|
+
import org.json.JSONObject;
|
|
48
|
+
|
|
49
|
+
public class CapgoUpdater {
|
|
50
|
+
|
|
51
|
+
private final Logger logger;
|
|
52
|
+
|
|
53
|
+
private static final String AB = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
54
|
+
private static final SecureRandom rnd = new SecureRandom();
|
|
55
|
+
|
|
56
|
+
private static final String INFO_SUFFIX = "_info";
|
|
57
|
+
|
|
58
|
+
private static final String FALLBACK_VERSION = "pastVersion";
|
|
59
|
+
private static final String NEXT_VERSION = "nextVersion";
|
|
60
|
+
private static final String bundleDirectory = "versions";
|
|
61
|
+
|
|
62
|
+
public static final String TAG = "Capacitor-updater";
|
|
63
|
+
public SharedPreferences.Editor editor;
|
|
64
|
+
public SharedPreferences prefs;
|
|
65
|
+
|
|
66
|
+
public File documentsDir;
|
|
67
|
+
public Boolean directUpdate = false;
|
|
68
|
+
public Activity activity;
|
|
69
|
+
public String pluginVersion = "";
|
|
70
|
+
public String versionBuild = "";
|
|
71
|
+
public String versionCode = "";
|
|
72
|
+
public String versionOs = "";
|
|
73
|
+
public String CAP_SERVER_PATH = "";
|
|
74
|
+
|
|
75
|
+
public String customId = "";
|
|
76
|
+
public String statsUrl = "";
|
|
77
|
+
public String channelUrl = "";
|
|
78
|
+
public String defaultChannel = "";
|
|
79
|
+
public String appId = "";
|
|
80
|
+
public String publicKey = "";
|
|
81
|
+
public String deviceID = "";
|
|
82
|
+
public int timeout = 20000;
|
|
83
|
+
|
|
84
|
+
// Flag to track if we received a 429 response - stops requests until app restart
|
|
85
|
+
private static volatile boolean rateLimitExceeded = false;
|
|
86
|
+
|
|
87
|
+
// Flag to track if we've already sent the rate limit statistic - prevents infinite loop
|
|
88
|
+
private static volatile boolean rateLimitStatisticSent = false;
|
|
89
|
+
|
|
90
|
+
private final Map<String, CompletableFuture<BundleInfo>> downloadFutures = new ConcurrentHashMap<>();
|
|
91
|
+
private final ExecutorService io = Executors.newSingleThreadExecutor();
|
|
92
|
+
|
|
93
|
+
public CapgoUpdater(Logger logger) {
|
|
94
|
+
this.logger = logger;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private final FilenameFilter filter = (f, name) -> {
|
|
98
|
+
// ignore directories generated by mac os x
|
|
99
|
+
return (!name.startsWith("__MACOSX") && !name.startsWith(".") && !name.startsWith(".DS_Store"));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
private boolean isProd() {
|
|
103
|
+
try {
|
|
104
|
+
return !Objects.requireNonNull(getClass().getPackage()).getName().contains(".debug");
|
|
105
|
+
} catch (Exception e) {
|
|
106
|
+
return true; // Default to production if we can't determine
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private boolean isEmulator() {
|
|
111
|
+
return (
|
|
112
|
+
(Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
|
|
113
|
+
Build.FINGERPRINT.startsWith("generic") ||
|
|
114
|
+
Build.FINGERPRINT.startsWith("unknown") ||
|
|
115
|
+
Build.HARDWARE.contains("goldfish") ||
|
|
116
|
+
Build.HARDWARE.contains("ranchu") ||
|
|
117
|
+
Build.MODEL.contains("google_sdk") ||
|
|
118
|
+
Build.MODEL.contains("Emulator") ||
|
|
119
|
+
Build.MODEL.contains("Android SDK built for x86") ||
|
|
120
|
+
Build.MANUFACTURER.contains("Genymotion") ||
|
|
121
|
+
Build.PRODUCT.contains("sdk_google") ||
|
|
122
|
+
Build.PRODUCT.contains("google_sdk") ||
|
|
123
|
+
Build.PRODUCT.contains("sdk") ||
|
|
124
|
+
Build.PRODUCT.contains("sdk_x86") ||
|
|
125
|
+
Build.PRODUCT.contains("sdk_gphone64_arm64") ||
|
|
126
|
+
Build.PRODUCT.contains("vbox86p") ||
|
|
127
|
+
Build.PRODUCT.contains("emulator") ||
|
|
128
|
+
Build.PRODUCT.contains("simulator")
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private int calcTotalPercent(final int percent, final int min, final int max) {
|
|
133
|
+
return (percent * (max - min)) / 100 + min;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
void notifyDownload(final String id, final int percent) {}
|
|
137
|
+
|
|
138
|
+
void directUpdateFinish(final BundleInfo latest) {}
|
|
139
|
+
|
|
140
|
+
void notifyListeners(final String id, final Map<String, Object> res) {}
|
|
141
|
+
|
|
142
|
+
public String randomString() {
|
|
143
|
+
final StringBuilder sb = new StringBuilder(10);
|
|
144
|
+
for (int i = 0; i < 10; i++) sb.append(AB.charAt(rnd.nextInt(AB.length())));
|
|
145
|
+
return sb.toString();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private File unzip(final String id, final File zipFile, final String dest) throws IOException {
|
|
149
|
+
final File targetDirectory = new File(this.documentsDir, dest);
|
|
150
|
+
try (
|
|
151
|
+
final BufferedInputStream bis = new BufferedInputStream(new FileInputStream(zipFile));
|
|
152
|
+
final ZipInputStream zis = new ZipInputStream(bis)
|
|
153
|
+
) {
|
|
154
|
+
int count;
|
|
155
|
+
final int bufferSize = 8192;
|
|
156
|
+
final byte[] buffer = new byte[bufferSize];
|
|
157
|
+
final long lengthTotal = zipFile.length();
|
|
158
|
+
long lengthRead = bufferSize;
|
|
159
|
+
int percent = 0;
|
|
160
|
+
this.notifyDownload(id, 75);
|
|
161
|
+
|
|
162
|
+
ZipEntry entry;
|
|
163
|
+
while ((entry = zis.getNextEntry()) != null) {
|
|
164
|
+
if (entry.getName().contains("\\")) {
|
|
165
|
+
logger.error("unzip: Windows path is not supported, please use unix path as require by zip RFC: " + entry.getName());
|
|
166
|
+
this.sendStats("windows_path_fail");
|
|
167
|
+
}
|
|
168
|
+
final File file = new File(targetDirectory, entry.getName());
|
|
169
|
+
final String canonicalPath = file.getCanonicalPath();
|
|
170
|
+
final String canonicalDir = targetDirectory.getCanonicalPath();
|
|
171
|
+
final File dir = entry.isDirectory() ? file : file.getParentFile();
|
|
172
|
+
|
|
173
|
+
if (!canonicalPath.startsWith(canonicalDir)) {
|
|
174
|
+
this.sendStats("canonical_path_fail");
|
|
175
|
+
throw new FileNotFoundException(
|
|
176
|
+
"SecurityException, Failed to ensure directory is the start path : " + canonicalDir + " of " + canonicalPath
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
assert dir != null;
|
|
181
|
+
if (!dir.isDirectory() && !dir.mkdirs()) {
|
|
182
|
+
this.sendStats("directory_path_fail");
|
|
183
|
+
throw new FileNotFoundException("Failed to ensure directory: " + dir.getAbsolutePath());
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (entry.isDirectory()) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try (final FileOutputStream outputStream = new FileOutputStream(file)) {
|
|
191
|
+
while ((count = zis.read(buffer)) != -1) outputStream.write(buffer, 0, count);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
final int newPercent = (int) ((lengthRead / (float) lengthTotal) * 100);
|
|
195
|
+
if (lengthTotal > 1 && newPercent != percent) {
|
|
196
|
+
percent = newPercent;
|
|
197
|
+
this.notifyDownload(id, this.calcTotalPercent(percent, 75, 90));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
lengthRead += entry.getCompressedSize();
|
|
201
|
+
}
|
|
202
|
+
return targetDirectory;
|
|
203
|
+
} catch (IOException e) {
|
|
204
|
+
this.sendStats("unzip_fail");
|
|
205
|
+
throw new IOException("Failed to unzip: " + zipFile.getPath());
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private void flattenAssets(final File sourceFile, final String dest) throws IOException {
|
|
210
|
+
if (!sourceFile.exists()) {
|
|
211
|
+
throw new FileNotFoundException("Source file not found: " + sourceFile.getPath());
|
|
212
|
+
}
|
|
213
|
+
final File destinationFile = new File(this.documentsDir, dest);
|
|
214
|
+
Objects.requireNonNull(destinationFile.getParentFile()).mkdirs();
|
|
215
|
+
final String[] entries = sourceFile.list(this.filter);
|
|
216
|
+
if (entries == null || entries.length == 0) {
|
|
217
|
+
throw new IOException("Source file was not a directory or was empty: " + sourceFile.getPath());
|
|
218
|
+
}
|
|
219
|
+
if (entries.length == 1 && !"index.html".equals(entries[0])) {
|
|
220
|
+
final File child = new File(sourceFile, entries[0]);
|
|
221
|
+
child.renameTo(destinationFile);
|
|
222
|
+
} else {
|
|
223
|
+
sourceFile.renameTo(destinationFile);
|
|
224
|
+
}
|
|
225
|
+
sourceFile.delete();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private void observeWorkProgress(Context context, String id) {
|
|
229
|
+
if (!(context instanceof LifecycleOwner)) {
|
|
230
|
+
logger.error("Context is not a LifecycleOwner, cannot observe work progress");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
activity.runOnUiThread(() -> {
|
|
235
|
+
WorkManager.getInstance(context)
|
|
236
|
+
.getWorkInfosByTagLiveData(id)
|
|
237
|
+
.observe((LifecycleOwner) context, (workInfos) -> {
|
|
238
|
+
if (workInfos == null || workInfos.isEmpty()) return;
|
|
239
|
+
|
|
240
|
+
WorkInfo workInfo = workInfos.get(0);
|
|
241
|
+
Data progress = workInfo.getProgress();
|
|
242
|
+
|
|
243
|
+
switch (workInfo.getState()) {
|
|
244
|
+
case RUNNING:
|
|
245
|
+
int percent = progress.getInt(DownloadService.PERCENT, 0);
|
|
246
|
+
notifyDownload(id, percent);
|
|
247
|
+
break;
|
|
248
|
+
case SUCCEEDED:
|
|
249
|
+
logger.info("Download succeeded: " + workInfo.getState());
|
|
250
|
+
Data outputData = workInfo.getOutputData();
|
|
251
|
+
String dest = outputData.getString(DownloadService.FILEDEST);
|
|
252
|
+
String version = outputData.getString(DownloadService.VERSION);
|
|
253
|
+
String sessionKey = outputData.getString(DownloadService.SESSIONKEY);
|
|
254
|
+
String checksum = outputData.getString(DownloadService.CHECKSUM);
|
|
255
|
+
boolean isManifest = outputData.getBoolean(DownloadService.IS_MANIFEST, false);
|
|
256
|
+
|
|
257
|
+
io.execute(() -> {
|
|
258
|
+
boolean success = finishDownload(id, dest, version, sessionKey, checksum, true, isManifest);
|
|
259
|
+
BundleInfo resultBundle;
|
|
260
|
+
if (!success) {
|
|
261
|
+
logger.error("Finish download failed: " + version);
|
|
262
|
+
resultBundle = new BundleInfo(
|
|
263
|
+
id,
|
|
264
|
+
version,
|
|
265
|
+
BundleStatus.ERROR,
|
|
266
|
+
new Date(System.currentTimeMillis()),
|
|
267
|
+
""
|
|
268
|
+
);
|
|
269
|
+
saveBundleInfo(id, resultBundle);
|
|
270
|
+
// Cleanup download tracking
|
|
271
|
+
DownloadWorkerManager.cancelBundleDownload(activity, id, version);
|
|
272
|
+
Map<String, Object> ret = new HashMap<>();
|
|
273
|
+
ret.put("version", version);
|
|
274
|
+
ret.put("error", "finish_download_fail");
|
|
275
|
+
sendStats("finish_download_fail", version);
|
|
276
|
+
notifyListeners("downloadFailed", ret);
|
|
277
|
+
} else {
|
|
278
|
+
// Successful download - cleanup tracking
|
|
279
|
+
DownloadWorkerManager.cancelBundleDownload(activity, id, version);
|
|
280
|
+
resultBundle = getBundleInfo(id);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Complete the future if it exists
|
|
284
|
+
CompletableFuture<BundleInfo> future = downloadFutures.remove(id);
|
|
285
|
+
if (future != null) {
|
|
286
|
+
future.complete(resultBundle);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
break;
|
|
290
|
+
case FAILED:
|
|
291
|
+
Data failedData = workInfo.getOutputData();
|
|
292
|
+
String error = failedData.getString(DownloadService.ERROR);
|
|
293
|
+
logger.error("Download failed: " + error + " " + workInfo.getState());
|
|
294
|
+
String failedVersion = failedData.getString(DownloadService.VERSION);
|
|
295
|
+
|
|
296
|
+
io.execute(() -> {
|
|
297
|
+
BundleInfo failedBundle = new BundleInfo(
|
|
298
|
+
id,
|
|
299
|
+
failedVersion,
|
|
300
|
+
BundleStatus.ERROR,
|
|
301
|
+
new Date(System.currentTimeMillis()),
|
|
302
|
+
""
|
|
303
|
+
);
|
|
304
|
+
saveBundleInfo(id, failedBundle);
|
|
305
|
+
// Cleanup download tracking for failed downloads
|
|
306
|
+
DownloadWorkerManager.cancelBundleDownload(activity, id, failedVersion);
|
|
307
|
+
Map<String, Object> ret = new HashMap<>();
|
|
308
|
+
ret.put("version", failedVersion);
|
|
309
|
+
if ("low_mem_fail".equals(error)) {
|
|
310
|
+
sendStats("low_mem_fail", failedVersion);
|
|
311
|
+
}
|
|
312
|
+
ret.put("error", error != null ? error : "download_fail");
|
|
313
|
+
sendStats("download_fail", failedVersion);
|
|
314
|
+
notifyListeners("downloadFailed", ret);
|
|
315
|
+
|
|
316
|
+
// Complete the future with error status
|
|
317
|
+
CompletableFuture<BundleInfo> failedFuture = downloadFutures.remove(id);
|
|
318
|
+
if (failedFuture != null) {
|
|
319
|
+
failedFuture.complete(failedBundle);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private void download(
|
|
329
|
+
final String id,
|
|
330
|
+
final String url,
|
|
331
|
+
final String dest,
|
|
332
|
+
final String version,
|
|
333
|
+
final String sessionKey,
|
|
334
|
+
final String checksum,
|
|
335
|
+
final JSONArray manifest
|
|
336
|
+
) {
|
|
337
|
+
if (this.activity == null) {
|
|
338
|
+
logger.error("Activity is null, cannot observe work progress");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
observeWorkProgress(this.activity, id);
|
|
342
|
+
|
|
343
|
+
DownloadWorkerManager.enqueueDownload(
|
|
344
|
+
this.activity,
|
|
345
|
+
url,
|
|
346
|
+
id,
|
|
347
|
+
this.documentsDir.getAbsolutePath(),
|
|
348
|
+
dest,
|
|
349
|
+
version,
|
|
350
|
+
sessionKey,
|
|
351
|
+
checksum,
|
|
352
|
+
this.publicKey,
|
|
353
|
+
manifest != null,
|
|
354
|
+
this.isEmulator(),
|
|
355
|
+
this.appId,
|
|
356
|
+
this.pluginVersion,
|
|
357
|
+
this.isProd(),
|
|
358
|
+
this.statsUrl,
|
|
359
|
+
this.deviceID,
|
|
360
|
+
this.versionBuild,
|
|
361
|
+
this.versionCode,
|
|
362
|
+
this.versionOs,
|
|
363
|
+
this.customId,
|
|
364
|
+
this.defaultChannel
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
if (manifest != null) {
|
|
368
|
+
DataManager.getInstance().setManifest(manifest);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
public Boolean finishDownload(
|
|
373
|
+
String id,
|
|
374
|
+
String dest,
|
|
375
|
+
String version,
|
|
376
|
+
String sessionKey,
|
|
377
|
+
String checksumRes,
|
|
378
|
+
Boolean setNext,
|
|
379
|
+
Boolean isManifest
|
|
380
|
+
) {
|
|
381
|
+
File downloaded = null;
|
|
382
|
+
File extractedDir = null;
|
|
383
|
+
String checksum = "";
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
this.notifyDownload(id, 71);
|
|
387
|
+
downloaded = new File(this.documentsDir, dest);
|
|
388
|
+
|
|
389
|
+
if (!isManifest) {
|
|
390
|
+
String checksumDecrypted = Objects.requireNonNullElse(checksumRes, "");
|
|
391
|
+
|
|
392
|
+
// If public key is present but no checksum provided, refuse installation
|
|
393
|
+
if (!this.publicKey.isEmpty() && checksumDecrypted.isEmpty()) {
|
|
394
|
+
logger.error("Public key present but no checksum provided");
|
|
395
|
+
this.sendStats("checksum_required");
|
|
396
|
+
throw new IOException("Checksum required when public key is present: " + id);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!sessionKey.isEmpty()) {
|
|
400
|
+
CryptoCipher.decryptFile(downloaded, publicKey, sessionKey);
|
|
401
|
+
checksumDecrypted = CryptoCipher.decryptChecksum(checksumRes, publicKey);
|
|
402
|
+
checksum = CryptoCipher.calcChecksum(downloaded);
|
|
403
|
+
} else {
|
|
404
|
+
checksum = CryptoCipher.calcChecksum(downloaded);
|
|
405
|
+
}
|
|
406
|
+
CryptoCipher.logChecksumInfo("Calculated checksum", checksum);
|
|
407
|
+
CryptoCipher.logChecksumInfo("Expected checksum", checksumDecrypted);
|
|
408
|
+
if ((!checksumDecrypted.isEmpty() || !this.publicKey.isEmpty()) && !checksumDecrypted.equals(checksum)) {
|
|
409
|
+
logger.error("Error checksum '" + checksumDecrypted + "' '" + checksum + "' '");
|
|
410
|
+
this.sendStats("checksum_fail");
|
|
411
|
+
throw new IOException("Checksum failed: " + id);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// Remove the decryption for manifest downloads
|
|
415
|
+
} catch (Exception e) {
|
|
416
|
+
if (!isManifest) {
|
|
417
|
+
safeDelete(downloaded);
|
|
418
|
+
}
|
|
419
|
+
final Boolean res = this.delete(id);
|
|
420
|
+
if (!res) {
|
|
421
|
+
logger.info("Double error, cannot cleanup: " + version);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
final Map<String, Object> ret = new HashMap<>();
|
|
425
|
+
ret.put("version", version);
|
|
426
|
+
|
|
427
|
+
CapgoUpdater.this.notifyListeners("downloadFailed", ret);
|
|
428
|
+
CapgoUpdater.this.sendStats("download_fail");
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
if (!isManifest) {
|
|
434
|
+
extractedDir = this.unzip(id, downloaded, this.randomString());
|
|
435
|
+
this.notifyDownload(id, 91);
|
|
436
|
+
final String idName = bundleDirectory + "/" + id;
|
|
437
|
+
this.flattenAssets(extractedDir, idName);
|
|
438
|
+
} else {
|
|
439
|
+
this.notifyDownload(id, 91);
|
|
440
|
+
final String idName = bundleDirectory + "/" + id;
|
|
441
|
+
this.flattenAssets(downloaded, idName);
|
|
442
|
+
downloaded.delete();
|
|
443
|
+
}
|
|
444
|
+
// Remove old bundle info and set new one
|
|
445
|
+
this.saveBundleInfo(id, null);
|
|
446
|
+
BundleInfo next = new BundleInfo(id, version, BundleStatus.PENDING, new Date(System.currentTimeMillis()), checksum);
|
|
447
|
+
this.saveBundleInfo(id, next);
|
|
448
|
+
this.notifyDownload(id, 100);
|
|
449
|
+
|
|
450
|
+
final Map<String, Object> ret = new HashMap<>();
|
|
451
|
+
ret.put("bundle", next.toJSONMap());
|
|
452
|
+
logger.info("updateAvailable: " + ret);
|
|
453
|
+
CapgoUpdater.this.notifyListeners("updateAvailable", ret);
|
|
454
|
+
logger.info("setNext: " + setNext);
|
|
455
|
+
if (setNext) {
|
|
456
|
+
logger.info("directUpdate: " + this.directUpdate);
|
|
457
|
+
if (this.directUpdate) {
|
|
458
|
+
CapgoUpdater.this.directUpdateFinish(next);
|
|
459
|
+
this.directUpdate = false;
|
|
460
|
+
} else {
|
|
461
|
+
this.setNextBundle(next.getId());
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
} catch (IOException e) {
|
|
465
|
+
if (!isManifest) {
|
|
466
|
+
safeDelete(extractedDir);
|
|
467
|
+
safeDelete(downloaded);
|
|
468
|
+
}
|
|
469
|
+
e.printStackTrace();
|
|
470
|
+
final Map<String, Object> ret = new HashMap<>();
|
|
471
|
+
ret.put("version", version);
|
|
472
|
+
CapgoUpdater.this.notifyListeners("downloadFailed", ret);
|
|
473
|
+
CapgoUpdater.this.sendStats("download_fail");
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
if (!isManifest) {
|
|
477
|
+
safeDelete(downloaded);
|
|
478
|
+
}
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private void deleteDirectory(final File file) throws IOException {
|
|
483
|
+
if (file.isDirectory()) {
|
|
484
|
+
final File[] entries = file.listFiles();
|
|
485
|
+
if (entries != null) {
|
|
486
|
+
for (final File entry : entries) {
|
|
487
|
+
this.deleteDirectory(entry);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (!file.delete()) {
|
|
492
|
+
throw new IOException("Failed to delete: " + file);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
public void cleanupDeltaCache() {
|
|
497
|
+
if (this.activity == null) {
|
|
498
|
+
logger.warn("Activity is null, skipping delta cache cleanup");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
final File cacheFolder = new File(this.activity.getCacheDir(), "capgo_downloads");
|
|
502
|
+
if (!cacheFolder.exists()) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
this.deleteDirectory(cacheFolder);
|
|
507
|
+
logger.info("Cleaned up delta cache folder");
|
|
508
|
+
} catch (IOException e) {
|
|
509
|
+
logger.error("Failed to cleanup delta cache: " + e.getMessage());
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
public void cleanupDownloadDirectories(final Set<String> allowedIds) {
|
|
514
|
+
if (this.documentsDir == null) {
|
|
515
|
+
logger.warn("Documents directory is null, skipping download cleanup");
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
final File bundleRoot = new File(this.documentsDir, bundleDirectory);
|
|
520
|
+
if (!bundleRoot.exists() || !bundleRoot.isDirectory()) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
final File[] entries = bundleRoot.listFiles();
|
|
525
|
+
if (entries != null) {
|
|
526
|
+
for (final File entry : entries) {
|
|
527
|
+
if (!entry.isDirectory()) {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
final String id = entry.getName();
|
|
532
|
+
|
|
533
|
+
if (allowedIds != null && allowedIds.contains(id)) {
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
try {
|
|
538
|
+
this.deleteDirectory(entry);
|
|
539
|
+
this.removeBundleInfo(id);
|
|
540
|
+
logger.info("Deleted orphan bundle directory: " + id);
|
|
541
|
+
} catch (IOException e) {
|
|
542
|
+
logger.error("Failed to delete orphan bundle directory: " + id + " " + e.getMessage());
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private void safeDelete(final File target) {
|
|
549
|
+
if (target == null || !target.exists()) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
if (target.isDirectory()) {
|
|
554
|
+
this.deleteDirectory(target);
|
|
555
|
+
} else if (!target.delete()) {
|
|
556
|
+
logger.warn("Failed to delete file: " + target.getAbsolutePath());
|
|
557
|
+
}
|
|
558
|
+
} catch (IOException cleanupError) {
|
|
559
|
+
logger.warn("Cleanup failed for " + target.getAbsolutePath() + ": " + cleanupError.getMessage());
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private void setCurrentBundle(final File bundle) {
|
|
564
|
+
this.editor.putString(this.CAP_SERVER_PATH, bundle.getPath());
|
|
565
|
+
logger.info("Current bundle set to: " + bundle);
|
|
566
|
+
this.editor.commit();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
public void downloadBackground(
|
|
570
|
+
final String url,
|
|
571
|
+
final String version,
|
|
572
|
+
final String sessionKey,
|
|
573
|
+
final String checksum,
|
|
574
|
+
final JSONArray manifest
|
|
575
|
+
) {
|
|
576
|
+
final String id = this.randomString();
|
|
577
|
+
|
|
578
|
+
// Check if version is already downloading, but allow retry if previous download failed
|
|
579
|
+
if (this.activity != null && DownloadWorkerManager.isVersionDownloading(this.activity, version)) {
|
|
580
|
+
// Check if there's an existing bundle with error status that we can retry
|
|
581
|
+
BundleInfo existingBundle = this.getBundleInfoByName(version);
|
|
582
|
+
if (existingBundle != null && existingBundle.isErrorStatus()) {
|
|
583
|
+
// Cancel the failed download and allow retry
|
|
584
|
+
DownloadWorkerManager.cancelVersionDownload(this.activity, version);
|
|
585
|
+
logger.info("Retrying failed download for version: " + version);
|
|
586
|
+
} else {
|
|
587
|
+
logger.info("Version already downloading: " + version);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), ""));
|
|
593
|
+
this.notifyDownload(id, 0);
|
|
594
|
+
this.notifyDownload(id, 5);
|
|
595
|
+
|
|
596
|
+
this.download(id, url, this.randomString(), version, sessionKey, checksum, manifest);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
public BundleInfo download(final String url, final String version, final String sessionKey, final String checksum) throws IOException {
|
|
600
|
+
// Check for existing bundle with same version and clean up if in error state
|
|
601
|
+
BundleInfo existingBundle = this.getBundleInfoByName(version);
|
|
602
|
+
if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted())) {
|
|
603
|
+
logger.info("Found existing failed bundle for version " + version + ", deleting before retry");
|
|
604
|
+
this.delete(existingBundle.getId(), true);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
final String id = this.randomString();
|
|
608
|
+
saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), ""));
|
|
609
|
+
this.notifyDownload(id, 0);
|
|
610
|
+
this.notifyDownload(id, 5);
|
|
611
|
+
final String dest = this.randomString();
|
|
612
|
+
|
|
613
|
+
// Create a CompletableFuture to track download completion
|
|
614
|
+
CompletableFuture<BundleInfo> downloadFuture = new CompletableFuture<>();
|
|
615
|
+
downloadFutures.put(id, downloadFuture);
|
|
616
|
+
|
|
617
|
+
// Start the download
|
|
618
|
+
this.download(id, url, dest, version, sessionKey, checksum, null);
|
|
619
|
+
|
|
620
|
+
// Wait for completion without timeout
|
|
621
|
+
try {
|
|
622
|
+
BundleInfo result = downloadFuture.get();
|
|
623
|
+
if (result.isErrorStatus()) {
|
|
624
|
+
throw new IOException("Download failed with status: " + result.getStatus());
|
|
625
|
+
}
|
|
626
|
+
return result;
|
|
627
|
+
} catch (Exception e) {
|
|
628
|
+
// Clean up on failure
|
|
629
|
+
downloadFutures.remove(id);
|
|
630
|
+
logger.error("Error waiting for download: " + e.getMessage());
|
|
631
|
+
BundleInfo errorBundle = new BundleInfo(id, version, BundleStatus.ERROR, new Date(System.currentTimeMillis()), "");
|
|
632
|
+
saveBundleInfo(id, errorBundle);
|
|
633
|
+
if (e instanceof IOException) {
|
|
634
|
+
throw (IOException) e;
|
|
635
|
+
}
|
|
636
|
+
throw new IOException("Error waiting for download: " + e.getMessage(), e);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
public List<BundleInfo> list(boolean rawList) {
|
|
641
|
+
if (!rawList) {
|
|
642
|
+
final List<BundleInfo> res = new ArrayList<>();
|
|
643
|
+
final File destHot = new File(this.documentsDir, bundleDirectory);
|
|
644
|
+
logger.debug("list File : " + destHot.getPath());
|
|
645
|
+
if (destHot.exists()) {
|
|
646
|
+
for (final File i : Objects.requireNonNull(destHot.listFiles())) {
|
|
647
|
+
final String id = i.getName();
|
|
648
|
+
res.add(this.getBundleInfo(id));
|
|
649
|
+
}
|
|
650
|
+
} else {
|
|
651
|
+
logger.info("No versions available to list" + destHot);
|
|
652
|
+
}
|
|
653
|
+
return res;
|
|
654
|
+
} else {
|
|
655
|
+
final List<BundleInfo> res = new ArrayList<>();
|
|
656
|
+
for (String value : this.prefs.getAll().keySet()) {
|
|
657
|
+
if (!value.matches("^[0-9A-Za-z]{10}_info$")) {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
res.add(this.getBundleInfo(value.split("_")[0]));
|
|
662
|
+
}
|
|
663
|
+
return res;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
public Boolean delete(final String id, final Boolean removeInfo) throws IOException {
|
|
668
|
+
final BundleInfo deleted = this.getBundleInfo(id);
|
|
669
|
+
if (deleted.isBuiltin() || this.getCurrentBundleId().equals(id)) {
|
|
670
|
+
logger.error("Cannot delete " + id);
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
final BundleInfo next = this.getNextBundle();
|
|
674
|
+
if (next != null && !next.isDeleted() && !next.isErrorStatus() && next.getId().equals(id)) {
|
|
675
|
+
logger.error("Cannot delete the next bundle" + id);
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
// Cancel download for this version if active
|
|
679
|
+
if (this.activity != null) {
|
|
680
|
+
DownloadWorkerManager.cancelVersionDownload(this.activity, deleted.getVersionName());
|
|
681
|
+
}
|
|
682
|
+
final File bundle = new File(this.documentsDir, bundleDirectory + "/" + id);
|
|
683
|
+
if (bundle.exists()) {
|
|
684
|
+
this.deleteDirectory(bundle);
|
|
685
|
+
if (!removeInfo) {
|
|
686
|
+
this.saveBundleInfo(id, deleted.setStatus(BundleStatus.DELETED));
|
|
687
|
+
} else {
|
|
688
|
+
this.removeBundleInfo(id);
|
|
689
|
+
}
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
logger.error("bundle removed: " + deleted.getVersionName());
|
|
693
|
+
// perhaps we did not find the bundle in the files, but if the user requested a delete, we delete
|
|
694
|
+
if (removeInfo) {
|
|
695
|
+
this.removeBundleInfo(id);
|
|
696
|
+
}
|
|
697
|
+
this.sendStats("delete", deleted.getVersionName());
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
public Boolean delete(final String id) {
|
|
702
|
+
try {
|
|
703
|
+
return this.delete(id, true);
|
|
704
|
+
} catch (IOException e) {
|
|
705
|
+
e.printStackTrace();
|
|
706
|
+
logger.info("Failed to delete bundle (" + id + ")" + "\nError:\n" + e.toString());
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private File getBundleDirectory(final String id) {
|
|
712
|
+
return new File(this.documentsDir, bundleDirectory + "/" + id);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private boolean bundleExists(final String id) {
|
|
716
|
+
final File bundle = this.getBundleDirectory(id);
|
|
717
|
+
final BundleInfo bundleInfo = this.getBundleInfo(id);
|
|
718
|
+
return (bundle.isDirectory() && bundle.exists() && new File(bundle.getPath(), "/index.html").exists() && !bundleInfo.isDeleted());
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
public Boolean set(final BundleInfo bundle) {
|
|
722
|
+
return this.set(bundle.getId());
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
public Boolean set(final String id) {
|
|
726
|
+
final BundleInfo newBundle = this.getBundleInfo(id);
|
|
727
|
+
if (newBundle.isBuiltin()) {
|
|
728
|
+
this.reset();
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
731
|
+
final File bundle = this.getBundleDirectory(id);
|
|
732
|
+
logger.info("Setting next active bundle: " + id);
|
|
733
|
+
if (this.bundleExists(id)) {
|
|
734
|
+
var currentBundleName = this.getCurrentBundle().getVersionName();
|
|
735
|
+
this.setCurrentBundle(bundle);
|
|
736
|
+
this.setBundleStatus(id, BundleStatus.PENDING);
|
|
737
|
+
this.sendStats("set", newBundle.getVersionName(), currentBundleName);
|
|
738
|
+
return true;
|
|
739
|
+
}
|
|
740
|
+
this.setBundleStatus(id, BundleStatus.ERROR);
|
|
741
|
+
this.sendStats("set_fail", newBundle.getVersionName());
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
public void autoReset() {
|
|
746
|
+
final BundleInfo currentBundle = this.getCurrentBundle();
|
|
747
|
+
if (!currentBundle.isBuiltin() && !this.bundleExists(currentBundle.getId())) {
|
|
748
|
+
logger.info("Folder at bundle path does not exist. Triggering reset.");
|
|
749
|
+
this.reset();
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
public void reset() {
|
|
754
|
+
this.reset(false);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
public void setSuccess(final BundleInfo bundle, Boolean autoDeletePrevious) {
|
|
758
|
+
this.setBundleStatus(bundle.getId(), BundleStatus.SUCCESS);
|
|
759
|
+
final BundleInfo fallback = this.getFallbackBundle();
|
|
760
|
+
logger.debug("Fallback bundle is: " + fallback);
|
|
761
|
+
logger.info("Version successfully loaded: " + bundle.getVersionName());
|
|
762
|
+
// Only attempt to delete when the fallback is a different bundle than the
|
|
763
|
+
// currently loaded one. Otherwise we spam logs with "Cannot delete <id>"
|
|
764
|
+
// because delete() protects the current bundle from removal.
|
|
765
|
+
if (autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != null && !fallback.getId().equals(bundle.getId())) {
|
|
766
|
+
final Boolean res = this.delete(fallback.getId());
|
|
767
|
+
if (res) {
|
|
768
|
+
logger.info("Deleted previous bundle: " + fallback.getVersionName());
|
|
769
|
+
} else {
|
|
770
|
+
logger.debug("Skip deleting previous bundle (same as current or protected): " + fallback.getId());
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
this.setFallbackBundle(bundle);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
public void setError(final BundleInfo bundle) {
|
|
777
|
+
this.setBundleStatus(bundle.getId(), BundleStatus.ERROR);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
public void reset(final boolean internal) {
|
|
781
|
+
logger.debug("reset: " + internal);
|
|
782
|
+
var currentBundleName = this.getCurrentBundle().getVersionName();
|
|
783
|
+
this.setCurrentBundle(new File("public"));
|
|
784
|
+
this.setFallbackBundle(null);
|
|
785
|
+
this.setNextBundle(null);
|
|
786
|
+
// Cancel any ongoing downloads
|
|
787
|
+
if (this.activity != null) {
|
|
788
|
+
DownloadWorkerManager.cancelAllDownloads(this.activity);
|
|
789
|
+
}
|
|
790
|
+
if (!internal) {
|
|
791
|
+
this.sendStats("reset", this.getCurrentBundle().getVersionName(), currentBundleName);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private JSONObject createInfoObject() throws JSONException {
|
|
796
|
+
JSONObject json = new JSONObject();
|
|
797
|
+
json.put("platform", "android");
|
|
798
|
+
json.put("device_id", this.deviceID);
|
|
799
|
+
json.put("app_id", this.appId);
|
|
800
|
+
json.put("custom_id", this.customId);
|
|
801
|
+
json.put("version_build", this.versionBuild);
|
|
802
|
+
json.put("version_code", this.versionCode);
|
|
803
|
+
json.put("version_os", this.versionOs);
|
|
804
|
+
json.put("version_name", this.getCurrentBundle().getVersionName());
|
|
805
|
+
json.put("plugin_version", this.pluginVersion);
|
|
806
|
+
json.put("is_emulator", this.isEmulator());
|
|
807
|
+
json.put("is_prod", this.isProd());
|
|
808
|
+
json.put("defaultChannel", this.defaultChannel);
|
|
809
|
+
return json;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Check if a 429 (Too Many Requests) response was received and set the flag
|
|
814
|
+
*/
|
|
815
|
+
private boolean checkAndHandleRateLimitResponse(Response response) {
|
|
816
|
+
if (response.code() == 429) {
|
|
817
|
+
// Send a statistic about the rate limit BEFORE setting the flag
|
|
818
|
+
// Only send once to prevent infinite loop if the stat request itself gets rate limited
|
|
819
|
+
if (!rateLimitExceeded && !rateLimitStatisticSent) {
|
|
820
|
+
rateLimitStatisticSent = true;
|
|
821
|
+
sendRateLimitStatistic();
|
|
822
|
+
}
|
|
823
|
+
rateLimitExceeded = true;
|
|
824
|
+
logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.");
|
|
825
|
+
return true;
|
|
826
|
+
}
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Send a synchronous statistic about rate limiting
|
|
832
|
+
*/
|
|
833
|
+
private void sendRateLimitStatistic() {
|
|
834
|
+
String statsUrl = this.statsUrl;
|
|
835
|
+
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
try {
|
|
840
|
+
BundleInfo current = this.getCurrentBundle();
|
|
841
|
+
JSONObject json = this.createInfoObject();
|
|
842
|
+
json.put("version_name", current.getVersionName());
|
|
843
|
+
json.put("old_version_name", "");
|
|
844
|
+
json.put("action", "rate_limit_reached");
|
|
845
|
+
|
|
846
|
+
Request request = new Request.Builder()
|
|
847
|
+
.url(statsUrl)
|
|
848
|
+
.post(RequestBody.create(json.toString(), MediaType.get("application/json")))
|
|
849
|
+
.build();
|
|
850
|
+
|
|
851
|
+
// Send synchronously to ensure it goes out before the flag is set
|
|
852
|
+
// User-Agent header is automatically added by DownloadService.sharedClient interceptor
|
|
853
|
+
try (Response response = DownloadService.sharedClient.newCall(request).execute()) {
|
|
854
|
+
if (response.isSuccessful()) {
|
|
855
|
+
logger.info("Rate limit statistic sent");
|
|
856
|
+
} else {
|
|
857
|
+
logger.error("Error sending rate limit statistic: " + response.code());
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
} catch (final Exception e) {
|
|
861
|
+
logger.error("Failed to send rate limit statistic: " + e.getMessage());
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
private void makeJsonRequest(String url, JSONObject jsonBody, Callback callback) {
|
|
866
|
+
MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
|
867
|
+
RequestBody body = RequestBody.create(jsonBody.toString(), JSON);
|
|
868
|
+
|
|
869
|
+
Request request = new Request.Builder().url(url).post(body).build();
|
|
870
|
+
|
|
871
|
+
DownloadService.sharedClient
|
|
872
|
+
.newCall(request)
|
|
873
|
+
.enqueue(
|
|
874
|
+
new okhttp3.Callback() {
|
|
875
|
+
@Override
|
|
876
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
877
|
+
Map<String, Object> retError = new HashMap<>();
|
|
878
|
+
retError.put("message", "Request failed: " + e.getMessage());
|
|
879
|
+
retError.put("error", "network_error");
|
|
880
|
+
callback.callback(retError);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
@Override
|
|
884
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
885
|
+
try (ResponseBody responseBody = response.body()) {
|
|
886
|
+
final int statusCode = response.code();
|
|
887
|
+
// Check for 429 rate limit
|
|
888
|
+
if (checkAndHandleRateLimitResponse(response)) {
|
|
889
|
+
Map<String, Object> retError = new HashMap<>();
|
|
890
|
+
retError.put("message", "Rate limit exceeded");
|
|
891
|
+
retError.put("error", "rate_limit_exceeded");
|
|
892
|
+
retError.put("statusCode", statusCode);
|
|
893
|
+
callback.callback(retError);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (!response.isSuccessful()) {
|
|
898
|
+
Map<String, Object> retError = new HashMap<>();
|
|
899
|
+
retError.put("message", "Server error: " + response.code());
|
|
900
|
+
retError.put("error", "response_error");
|
|
901
|
+
retError.put("statusCode", statusCode);
|
|
902
|
+
callback.callback(retError);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
assert responseBody != null;
|
|
907
|
+
String responseData = responseBody.string();
|
|
908
|
+
JSONObject jsonResponse = new JSONObject(responseData);
|
|
909
|
+
|
|
910
|
+
// Check for server-side errors first
|
|
911
|
+
if (jsonResponse.has("error")) {
|
|
912
|
+
Map<String, Object> retError = new HashMap<>();
|
|
913
|
+
retError.put("error", jsonResponse.getString("error"));
|
|
914
|
+
if (jsonResponse.has("message")) {
|
|
915
|
+
retError.put("message", jsonResponse.getString("message"));
|
|
916
|
+
} else {
|
|
917
|
+
retError.put("message", "server did not provide a message");
|
|
918
|
+
}
|
|
919
|
+
retError.put("statusCode", statusCode);
|
|
920
|
+
callback.callback(retError);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
Map<String, Object> ret = new HashMap<>();
|
|
925
|
+
ret.put("statusCode", statusCode);
|
|
926
|
+
|
|
927
|
+
Iterator<String> keys = jsonResponse.keys();
|
|
928
|
+
while (keys.hasNext()) {
|
|
929
|
+
String key = keys.next();
|
|
930
|
+
if (jsonResponse.has(key)) {
|
|
931
|
+
if ("session_key".equals(key)) {
|
|
932
|
+
ret.put("sessionKey", jsonResponse.get(key));
|
|
933
|
+
} else {
|
|
934
|
+
ret.put(key, jsonResponse.get(key));
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
callback.callback(ret);
|
|
939
|
+
} catch (JSONException e) {
|
|
940
|
+
Map<String, Object> retError = new HashMap<>();
|
|
941
|
+
retError.put("message", "JSON parse error: " + e.getMessage());
|
|
942
|
+
retError.put("error", "parse_error");
|
|
943
|
+
callback.callback(retError);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
public void getLatest(final String updateUrl, final String channel, final Callback callback) {
|
|
951
|
+
JSONObject json;
|
|
952
|
+
try {
|
|
953
|
+
json = this.createInfoObject();
|
|
954
|
+
if (channel != null && json != null) {
|
|
955
|
+
json.put("defaultChannel", channel);
|
|
956
|
+
}
|
|
957
|
+
} catch (JSONException e) {
|
|
958
|
+
logger.error("Error getLatest JSONException " + e.getMessage());
|
|
959
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
960
|
+
retError.put("message", "Cannot get info: " + e);
|
|
961
|
+
retError.put("error", "json_error");
|
|
962
|
+
callback.callback(retError);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
logger.info("Auto-update parameters: " + json);
|
|
967
|
+
|
|
968
|
+
makeJsonRequest(updateUrl, json, callback);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
public void unsetChannel(
|
|
972
|
+
final SharedPreferences.Editor editor,
|
|
973
|
+
final String defaultChannelKey,
|
|
974
|
+
final String configDefaultChannel,
|
|
975
|
+
final Callback callback
|
|
976
|
+
) {
|
|
977
|
+
// Clear persisted defaultChannel and revert to config value
|
|
978
|
+
editor.remove(defaultChannelKey);
|
|
979
|
+
editor.apply();
|
|
980
|
+
this.defaultChannel = configDefaultChannel;
|
|
981
|
+
logger.info("Persisted defaultChannel cleared, reverted to config value: " + configDefaultChannel);
|
|
982
|
+
|
|
983
|
+
Map<String, Object> ret = new HashMap<>();
|
|
984
|
+
ret.put("status", "ok");
|
|
985
|
+
ret.put("message", "Channel override removed");
|
|
986
|
+
callback.callback(ret);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
public void setChannel(
|
|
990
|
+
final String channel,
|
|
991
|
+
final SharedPreferences.Editor editor,
|
|
992
|
+
final String defaultChannelKey,
|
|
993
|
+
final boolean allowSetDefaultChannel,
|
|
994
|
+
final Callback callback
|
|
995
|
+
) {
|
|
996
|
+
// Check if setting defaultChannel is allowed
|
|
997
|
+
if (!allowSetDefaultChannel) {
|
|
998
|
+
logger.error("setChannel is disabled by allowSetDefaultChannel config");
|
|
999
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
1000
|
+
retError.put("message", "setChannel is disabled by configuration");
|
|
1001
|
+
retError.put("error", "disabled_by_config");
|
|
1002
|
+
callback.callback(retError);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Check if rate limit was exceeded
|
|
1007
|
+
if (rateLimitExceeded) {
|
|
1008
|
+
logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.");
|
|
1009
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
1010
|
+
retError.put("message", "Rate limit exceeded");
|
|
1011
|
+
retError.put("error", "rate_limit_exceeded");
|
|
1012
|
+
callback.callback(retError);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
String channelUrl = this.channelUrl;
|
|
1017
|
+
if (channelUrl == null || channelUrl.isEmpty()) {
|
|
1018
|
+
logger.error("Channel URL is not set");
|
|
1019
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
1020
|
+
retError.put("message", "channelUrl missing");
|
|
1021
|
+
retError.put("error", "missing_config");
|
|
1022
|
+
callback.callback(retError);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
JSONObject json;
|
|
1026
|
+
try {
|
|
1027
|
+
json = this.createInfoObject();
|
|
1028
|
+
json.put("channel", channel);
|
|
1029
|
+
} catch (JSONException e) {
|
|
1030
|
+
logger.error("Error setChannel JSONException " + e.getMessage());
|
|
1031
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
1032
|
+
retError.put("message", "Cannot get info: " + e);
|
|
1033
|
+
retError.put("error", "json_error");
|
|
1034
|
+
callback.callback(retError);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
makeJsonRequest(channelUrl, json, (res) -> {
|
|
1039
|
+
if (res.containsKey("error")) {
|
|
1040
|
+
callback.callback(res);
|
|
1041
|
+
} else {
|
|
1042
|
+
// Success - persist defaultChannel
|
|
1043
|
+
this.defaultChannel = channel;
|
|
1044
|
+
editor.putString(defaultChannelKey, channel);
|
|
1045
|
+
editor.apply();
|
|
1046
|
+
logger.info("defaultChannel persisted locally: " + channel);
|
|
1047
|
+
callback.callback(res);
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
public void getChannel(final Callback callback) {
|
|
1053
|
+
// Check if rate limit was exceeded
|
|
1054
|
+
if (rateLimitExceeded) {
|
|
1055
|
+
logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.");
|
|
1056
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
1057
|
+
retError.put("message", "Rate limit exceeded");
|
|
1058
|
+
retError.put("error", "rate_limit_exceeded");
|
|
1059
|
+
callback.callback(retError);
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
String channelUrl = this.channelUrl;
|
|
1064
|
+
if (channelUrl == null || channelUrl.isEmpty()) {
|
|
1065
|
+
logger.error("Channel URL is not set");
|
|
1066
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
1067
|
+
retError.put("message", "Channel URL is not set");
|
|
1068
|
+
retError.put("error", "missing_config");
|
|
1069
|
+
callback.callback(retError);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
JSONObject json;
|
|
1073
|
+
try {
|
|
1074
|
+
json = this.createInfoObject();
|
|
1075
|
+
} catch (JSONException e) {
|
|
1076
|
+
logger.error("Error getChannel JSONException " + e.getMessage());
|
|
1077
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
1078
|
+
retError.put("message", "Cannot get info: " + e);
|
|
1079
|
+
retError.put("error", "json_error");
|
|
1080
|
+
callback.callback(retError);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
Request request = new Request.Builder()
|
|
1085
|
+
.url(channelUrl)
|
|
1086
|
+
.put(RequestBody.create(json.toString(), MediaType.get("application/json")))
|
|
1087
|
+
.build();
|
|
1088
|
+
|
|
1089
|
+
DownloadService.sharedClient
|
|
1090
|
+
.newCall(request)
|
|
1091
|
+
.enqueue(
|
|
1092
|
+
new okhttp3.Callback() {
|
|
1093
|
+
@Override
|
|
1094
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
1095
|
+
Map<String, Object> retError = new HashMap<>();
|
|
1096
|
+
retError.put("message", "Request failed: " + e.getMessage());
|
|
1097
|
+
retError.put("error", "network_error");
|
|
1098
|
+
callback.callback(retError);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
@Override
|
|
1102
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
1103
|
+
try (ResponseBody responseBody = response.body()) {
|
|
1104
|
+
// Check for 429 rate limit
|
|
1105
|
+
if (checkAndHandleRateLimitResponse(response)) {
|
|
1106
|
+
Map<String, Object> retError = new HashMap<>();
|
|
1107
|
+
retError.put("message", "Rate limit exceeded");
|
|
1108
|
+
retError.put("error", "rate_limit_exceeded");
|
|
1109
|
+
callback.callback(retError);
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (response.code() == 400) {
|
|
1114
|
+
assert responseBody != null;
|
|
1115
|
+
String data = responseBody.string();
|
|
1116
|
+
if (data.contains("channel_not_found") && !defaultChannel.isEmpty()) {
|
|
1117
|
+
Map<String, Object> ret = new HashMap<>();
|
|
1118
|
+
ret.put("channel", defaultChannel);
|
|
1119
|
+
ret.put("status", "default");
|
|
1120
|
+
logger.info("Channel get to \"" + ret);
|
|
1121
|
+
callback.callback(ret);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (!response.isSuccessful()) {
|
|
1127
|
+
Map<String, Object> retError = new HashMap<>();
|
|
1128
|
+
retError.put("message", "Server error: " + response.code());
|
|
1129
|
+
retError.put("error", "response_error");
|
|
1130
|
+
callback.callback(retError);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
assert responseBody != null;
|
|
1135
|
+
String responseData = responseBody.string();
|
|
1136
|
+
JSONObject jsonResponse = new JSONObject(responseData);
|
|
1137
|
+
|
|
1138
|
+
// Check for server-side errors first
|
|
1139
|
+
if (jsonResponse.has("error")) {
|
|
1140
|
+
Map<String, Object> retError = new HashMap<>();
|
|
1141
|
+
retError.put("error", jsonResponse.getString("error"));
|
|
1142
|
+
if (jsonResponse.has("message")) {
|
|
1143
|
+
retError.put("message", jsonResponse.getString("message"));
|
|
1144
|
+
} else {
|
|
1145
|
+
retError.put("message", "server did not provide a message");
|
|
1146
|
+
}
|
|
1147
|
+
callback.callback(retError);
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
Map<String, Object> ret = new HashMap<>();
|
|
1152
|
+
|
|
1153
|
+
Iterator<String> keys = jsonResponse.keys();
|
|
1154
|
+
while (keys.hasNext()) {
|
|
1155
|
+
String key = keys.next();
|
|
1156
|
+
if (jsonResponse.has(key)) {
|
|
1157
|
+
ret.put(key, jsonResponse.get(key));
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
logger.info("Channel get to \"" + ret);
|
|
1161
|
+
callback.callback(ret);
|
|
1162
|
+
} catch (JSONException e) {
|
|
1163
|
+
Map<String, Object> retError = new HashMap<>();
|
|
1164
|
+
retError.put("message", "JSON parse error: " + e.getMessage());
|
|
1165
|
+
retError.put("error", "parse_error");
|
|
1166
|
+
callback.callback(retError);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
public void listChannels(final Callback callback) {
|
|
1174
|
+
// Check if rate limit was exceeded
|
|
1175
|
+
if (rateLimitExceeded) {
|
|
1176
|
+
logger.debug("Skipping listChannels due to rate limit (429). Requests will resume after app restart.");
|
|
1177
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
1178
|
+
retError.put("message", "Rate limit exceeded");
|
|
1179
|
+
retError.put("error", "rate_limit_exceeded");
|
|
1180
|
+
callback.callback(retError);
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
String channelUrl = this.channelUrl;
|
|
1185
|
+
if (channelUrl == null || channelUrl.isEmpty()) {
|
|
1186
|
+
logger.error("Channel URL is not set");
|
|
1187
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
1188
|
+
retError.put("message", "Channel URL is not set");
|
|
1189
|
+
retError.put("error", "missing_config");
|
|
1190
|
+
callback.callback(retError);
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
JSONObject json;
|
|
1195
|
+
try {
|
|
1196
|
+
json = this.createInfoObject();
|
|
1197
|
+
} catch (JSONException e) {
|
|
1198
|
+
logger.error("Error creating info object: " + e.getMessage());
|
|
1199
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
1200
|
+
retError.put("message", "Cannot get info: " + e);
|
|
1201
|
+
retError.put("error", "json_error");
|
|
1202
|
+
callback.callback(retError);
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Build URL with query parameters from JSON
|
|
1207
|
+
HttpUrl.Builder urlBuilder = HttpUrl.parse(channelUrl).newBuilder();
|
|
1208
|
+
try {
|
|
1209
|
+
Iterator<String> keys = json.keys();
|
|
1210
|
+
while (keys.hasNext()) {
|
|
1211
|
+
String key = keys.next();
|
|
1212
|
+
Object value = json.get(key);
|
|
1213
|
+
if (value != null) {
|
|
1214
|
+
urlBuilder.addQueryParameter(key, value.toString());
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
} catch (JSONException e) {
|
|
1218
|
+
logger.error("Error adding query parameters: " + e.getMessage());
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
Request request = new Request.Builder().url(urlBuilder.build()).get().build();
|
|
1222
|
+
|
|
1223
|
+
DownloadService.sharedClient
|
|
1224
|
+
.newCall(request)
|
|
1225
|
+
.enqueue(
|
|
1226
|
+
new okhttp3.Callback() {
|
|
1227
|
+
@Override
|
|
1228
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
1229
|
+
Map<String, Object> retError = new HashMap<>();
|
|
1230
|
+
retError.put("message", "Request failed: " + e.getMessage());
|
|
1231
|
+
retError.put("error", "network_error");
|
|
1232
|
+
callback.callback(retError);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
@Override
|
|
1236
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
1237
|
+
try (ResponseBody responseBody = response.body()) {
|
|
1238
|
+
// Check for 429 rate limit
|
|
1239
|
+
if (checkAndHandleRateLimitResponse(response)) {
|
|
1240
|
+
Map<String, Object> retError = new HashMap<>();
|
|
1241
|
+
retError.put("message", "Rate limit exceeded");
|
|
1242
|
+
retError.put("error", "rate_limit_exceeded");
|
|
1243
|
+
callback.callback(retError);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (!response.isSuccessful()) {
|
|
1248
|
+
Map<String, Object> retError = new HashMap<>();
|
|
1249
|
+
retError.put("message", "Server error: " + response.code());
|
|
1250
|
+
retError.put("error", "response_error");
|
|
1251
|
+
callback.callback(retError);
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
assert responseBody != null;
|
|
1256
|
+
String data = responseBody.string();
|
|
1257
|
+
|
|
1258
|
+
try {
|
|
1259
|
+
Map<String, Object> ret = new HashMap<>();
|
|
1260
|
+
|
|
1261
|
+
try {
|
|
1262
|
+
// Try to parse as direct array first
|
|
1263
|
+
JSONArray channelsJson = new JSONArray(data);
|
|
1264
|
+
List<Map<String, Object>> channelsList = new ArrayList<>();
|
|
1265
|
+
|
|
1266
|
+
for (int i = 0; i < channelsJson.length(); i++) {
|
|
1267
|
+
JSONObject channelJson = channelsJson.getJSONObject(i);
|
|
1268
|
+
Map<String, Object> channel = new HashMap<>();
|
|
1269
|
+
channel.put("id", channelJson.optString("id", ""));
|
|
1270
|
+
channel.put("name", channelJson.optString("name", ""));
|
|
1271
|
+
channel.put("public", channelJson.optBoolean("public", false));
|
|
1272
|
+
channel.put("allow_self_set", channelJson.optBoolean("allow_self_set", false));
|
|
1273
|
+
channelsList.add(channel);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// Wrap in channels object for JS API
|
|
1277
|
+
ret.put("channels", channelsList);
|
|
1278
|
+
|
|
1279
|
+
logger.info("Channels listed successfully");
|
|
1280
|
+
callback.callback(ret);
|
|
1281
|
+
} catch (JSONException arrayException) {
|
|
1282
|
+
// If not an array, try to parse as error object
|
|
1283
|
+
try {
|
|
1284
|
+
JSONObject json = new JSONObject(data);
|
|
1285
|
+
if (json.has("error")) {
|
|
1286
|
+
Map<String, Object> retError = new HashMap<>();
|
|
1287
|
+
retError.put("error", json.getString("error"));
|
|
1288
|
+
if (json.has("message")) {
|
|
1289
|
+
retError.put("message", json.getString("message"));
|
|
1290
|
+
} else {
|
|
1291
|
+
retError.put("message", "server did not provide a message");
|
|
1292
|
+
}
|
|
1293
|
+
callback.callback(retError);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
} catch (JSONException objException) {
|
|
1297
|
+
// If neither array nor object, throw parse error
|
|
1298
|
+
throw arrayException;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
} catch (JSONException e) {
|
|
1302
|
+
Map<String, Object> retError = new HashMap<>();
|
|
1303
|
+
retError.put("message", "JSON parse error: " + e.getMessage());
|
|
1304
|
+
retError.put("error", "parse_error");
|
|
1305
|
+
callback.callback(retError);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
public void sendStats(final String action) {
|
|
1314
|
+
this.sendStats(action, this.getCurrentBundle().getVersionName());
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
public void sendStats(final String action, final String versionName) {
|
|
1318
|
+
this.sendStats(action, versionName, "");
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
public void sendStats(final String action, final String versionName, final String oldVersionName) {
|
|
1322
|
+
// Check if rate limit was exceeded
|
|
1323
|
+
if (rateLimitExceeded) {
|
|
1324
|
+
logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.");
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
String statsUrl = this.statsUrl;
|
|
1329
|
+
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
JSONObject json;
|
|
1333
|
+
try {
|
|
1334
|
+
json = this.createInfoObject();
|
|
1335
|
+
json.put("version_name", versionName);
|
|
1336
|
+
json.put("old_version_name", oldVersionName);
|
|
1337
|
+
json.put("action", action);
|
|
1338
|
+
} catch (JSONException e) {
|
|
1339
|
+
logger.error("Error sendStats JSONException " + e.getMessage());
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
Request request = new Request.Builder()
|
|
1344
|
+
.url(statsUrl)
|
|
1345
|
+
.post(RequestBody.create(json.toString(), MediaType.get("application/json")))
|
|
1346
|
+
.build();
|
|
1347
|
+
|
|
1348
|
+
DownloadService.sharedClient
|
|
1349
|
+
.newCall(request)
|
|
1350
|
+
.enqueue(
|
|
1351
|
+
new okhttp3.Callback() {
|
|
1352
|
+
@Override
|
|
1353
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
1354
|
+
logger.error("Failed to send stats: " + e.getMessage());
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
@Override
|
|
1358
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
1359
|
+
try (ResponseBody responseBody = response.body()) {
|
|
1360
|
+
// Check for 429 rate limit
|
|
1361
|
+
if (checkAndHandleRateLimitResponse(response)) {
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (response.isSuccessful()) {
|
|
1366
|
+
logger.info("Stats send for \"" + action + "\", version " + versionName);
|
|
1367
|
+
} else {
|
|
1368
|
+
logger.error("Error sending stats: " + response.code());
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
public BundleInfo getBundleInfo(final String id) {
|
|
1377
|
+
String trueId = BundleInfo.VERSION_UNKNOWN;
|
|
1378
|
+
if (id != null) {
|
|
1379
|
+
trueId = id;
|
|
1380
|
+
}
|
|
1381
|
+
BundleInfo result;
|
|
1382
|
+
if (BundleInfo.ID_BUILTIN.equals(trueId)) {
|
|
1383
|
+
result = new BundleInfo(trueId, null, BundleStatus.SUCCESS, "", "");
|
|
1384
|
+
} else if (BundleInfo.VERSION_UNKNOWN.equals(trueId)) {
|
|
1385
|
+
result = new BundleInfo(trueId, null, BundleStatus.ERROR, "", "");
|
|
1386
|
+
} else {
|
|
1387
|
+
try {
|
|
1388
|
+
String stored = this.prefs.getString(trueId + INFO_SUFFIX, "");
|
|
1389
|
+
if (stored.isEmpty()) {
|
|
1390
|
+
result = new BundleInfo(trueId, null, BundleStatus.PENDING, "", "");
|
|
1391
|
+
} else {
|
|
1392
|
+
result = BundleInfo.fromJSON(stored);
|
|
1393
|
+
}
|
|
1394
|
+
} catch (JSONException e) {
|
|
1395
|
+
logger.error(
|
|
1396
|
+
"Failed to parse info for bundle [" +
|
|
1397
|
+
trueId +
|
|
1398
|
+
"] stored value: '" +
|
|
1399
|
+
this.prefs.getString(trueId + INFO_SUFFIX, "") +
|
|
1400
|
+
"' error: " +
|
|
1401
|
+
e.getMessage()
|
|
1402
|
+
);
|
|
1403
|
+
// Clear corrupted data
|
|
1404
|
+
this.editor.remove(trueId + INFO_SUFFIX);
|
|
1405
|
+
this.editor.commit();
|
|
1406
|
+
result = new BundleInfo(trueId, null, BundleStatus.ERROR, "", "");
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
return result;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
public BundleInfo getBundleInfoByName(final String versionName) {
|
|
1413
|
+
final List<BundleInfo> installed = this.list(false);
|
|
1414
|
+
for (final BundleInfo i : installed) {
|
|
1415
|
+
if (i.getVersionName().equals(versionName)) {
|
|
1416
|
+
return i;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
private void removeBundleInfo(final String id) {
|
|
1423
|
+
this.saveBundleInfo(id, null);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
public void saveBundleInfo(final String id, final BundleInfo info) {
|
|
1427
|
+
if (id == null || (info != null && (info.isBuiltin() || info.isUnknown()))) {
|
|
1428
|
+
logger.debug("Not saving info for bundle: [" + id + "] " + info);
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
if (info == null) {
|
|
1433
|
+
logger.debug("Removing info for bundle [" + id + "]");
|
|
1434
|
+
this.editor.remove(id + INFO_SUFFIX);
|
|
1435
|
+
} else {
|
|
1436
|
+
final BundleInfo update = info.setId(id);
|
|
1437
|
+
String jsonString = update.toString();
|
|
1438
|
+
logger.debug("Storing info for bundle [" + id + "] " + update.getClass().getName() + " -> " + jsonString);
|
|
1439
|
+
this.editor.putString(id + INFO_SUFFIX, jsonString);
|
|
1440
|
+
}
|
|
1441
|
+
this.editor.commit();
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
private void setBundleStatus(final String id, final BundleStatus status) {
|
|
1445
|
+
if (id != null && status != null) {
|
|
1446
|
+
BundleInfo info = this.getBundleInfo(id);
|
|
1447
|
+
logger.debug("Setting status for bundle [" + id + "] to " + status);
|
|
1448
|
+
this.saveBundleInfo(id, info.setStatus(status));
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
private String getCurrentBundleId() {
|
|
1453
|
+
if (this.isUsingBuiltin()) {
|
|
1454
|
+
return BundleInfo.ID_BUILTIN;
|
|
1455
|
+
} else {
|
|
1456
|
+
final String path = this.getCurrentBundlePath();
|
|
1457
|
+
return path.substring(path.lastIndexOf('/') + 1);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
public BundleInfo getCurrentBundle() {
|
|
1462
|
+
return this.getBundleInfo(this.getCurrentBundleId());
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
public String getCurrentBundlePath() {
|
|
1466
|
+
String path = this.prefs.getString(this.CAP_SERVER_PATH, "public");
|
|
1467
|
+
if (path.trim().isEmpty()) {
|
|
1468
|
+
return "public";
|
|
1469
|
+
}
|
|
1470
|
+
return path;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
public Boolean isUsingBuiltin() {
|
|
1474
|
+
return this.getCurrentBundlePath().equals("public");
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
public BundleInfo getFallbackBundle() {
|
|
1478
|
+
final String id = this.prefs.getString(FALLBACK_VERSION, BundleInfo.ID_BUILTIN);
|
|
1479
|
+
return this.getBundleInfo(id);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
private void setFallbackBundle(final BundleInfo fallback) {
|
|
1483
|
+
this.editor.putString(FALLBACK_VERSION, fallback == null ? BundleInfo.ID_BUILTIN : fallback.getId());
|
|
1484
|
+
this.editor.commit();
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
public BundleInfo getNextBundle() {
|
|
1488
|
+
final String id = this.prefs.getString(NEXT_VERSION, null);
|
|
1489
|
+
if (id == null) return null;
|
|
1490
|
+
return this.getBundleInfo(id);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
public boolean setNextBundle(final String next) {
|
|
1494
|
+
if (next == null) {
|
|
1495
|
+
this.editor.remove(NEXT_VERSION);
|
|
1496
|
+
} else {
|
|
1497
|
+
final BundleInfo newBundle = this.getBundleInfo(next);
|
|
1498
|
+
if (!newBundle.isBuiltin() && !this.bundleExists(next)) {
|
|
1499
|
+
return false;
|
|
1500
|
+
}
|
|
1501
|
+
this.editor.putString(NEXT_VERSION, next);
|
|
1502
|
+
this.setBundleStatus(next, BundleStatus.PENDING);
|
|
1503
|
+
}
|
|
1504
|
+
this.editor.commit();
|
|
1505
|
+
return true;
|
|
1506
|
+
}
|
|
1507
|
+
}
|