@otakit/capacitor-updater 1.0.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/LICENSE +21 -0
- package/README.md +183 -0
- package/UpdatekitUpdater.podspec +18 -0
- package/android/build.gradle +49 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/updatekit/updater/BundleInfo.java +100 -0
- package/android/src/main/java/com/updatekit/updater/BundleStatus.java +28 -0
- package/android/src/main/java/com/updatekit/updater/BundleStore.java +264 -0
- package/android/src/main/java/com/updatekit/updater/DateUtils.java +17 -0
- package/android/src/main/java/com/updatekit/updater/HashUtils.java +32 -0
- package/android/src/main/java/com/updatekit/updater/HostedManifestKeys.java +37 -0
- package/android/src/main/java/com/updatekit/updater/ManifestClient.java +209 -0
- package/android/src/main/java/com/updatekit/updater/ManifestVerifier.java +146 -0
- package/android/src/main/java/com/updatekit/updater/StatsClient.java +80 -0
- package/android/src/main/java/com/updatekit/updater/UpdaterPlugin.java +963 -0
- package/android/src/main/java/com/updatekit/updater/ZipUtils.java +72 -0
- package/dist/esm/definitions.d.ts +229 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +17 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +85 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +25 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +56 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +165 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +168 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/UpdaterPlugin/BundleInfo.swift +39 -0
- package/ios/Sources/UpdaterPlugin/BundleStatus.swift +9 -0
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +233 -0
- package/ios/Sources/UpdaterPlugin/Downloader.swift +120 -0
- package/ios/Sources/UpdaterPlugin/HashUtils.swift +33 -0
- package/ios/Sources/UpdaterPlugin/HostedManifestKeys.swift +23 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +161 -0
- package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +115 -0
- package/ios/Sources/UpdaterPlugin/StatsClient.swift +66 -0
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.m +15 -0
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +913 -0
- package/ios/Sources/UpdaterPlugin/ZipUtils.swift +90 -0
- package/package.json +85 -0
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
package com.updatekit.updater;
|
|
2
|
+
|
|
3
|
+
import android.content.pm.PackageInfo;
|
|
4
|
+
import android.content.pm.PackageManager;
|
|
5
|
+
import android.os.Build;
|
|
6
|
+
import android.os.Handler;
|
|
7
|
+
import android.os.Looper;
|
|
8
|
+
import com.getcapacitor.JSArray;
|
|
9
|
+
import com.getcapacitor.JSObject;
|
|
10
|
+
import com.getcapacitor.Plugin;
|
|
11
|
+
import com.getcapacitor.PluginCall;
|
|
12
|
+
import com.getcapacitor.PluginMethod;
|
|
13
|
+
import com.getcapacitor.annotation.CapacitorPlugin;
|
|
14
|
+
import java.io.File;
|
|
15
|
+
import java.io.FileInputStream;
|
|
16
|
+
import java.io.FileOutputStream;
|
|
17
|
+
import java.io.InputStream;
|
|
18
|
+
import java.net.HttpURLConnection;
|
|
19
|
+
import java.net.URL;
|
|
20
|
+
import java.nio.charset.StandardCharsets;
|
|
21
|
+
import java.security.MessageDigest;
|
|
22
|
+
import java.util.concurrent.ExecutorService;
|
|
23
|
+
import java.util.concurrent.Executors;
|
|
24
|
+
|
|
25
|
+
@CapacitorPlugin(name = "OtaKit")
|
|
26
|
+
public class UpdaterPlugin extends Plugin {
|
|
27
|
+
|
|
28
|
+
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
|
29
|
+
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
|
30
|
+
private final ZipUtils zipUtils = new ZipUtils();
|
|
31
|
+
|
|
32
|
+
private BundleStore store;
|
|
33
|
+
private Runnable trialTimeoutRunnable;
|
|
34
|
+
|
|
35
|
+
private int appReadyTimeoutMs = 10_000;
|
|
36
|
+
private boolean allowInsecureUrls = false;
|
|
37
|
+
private String updateMode = UPDATE_MODE_NEXT_LAUNCH;
|
|
38
|
+
private String updateUrl;
|
|
39
|
+
private String appId;
|
|
40
|
+
private String channel;
|
|
41
|
+
private java.util.List<ManifestVerifier.KeyEntry> manifestKeys = new java.util.ArrayList<>();
|
|
42
|
+
private static final String DEFAULT_UPDATE_URL = "https://www.otakit.app/api/v1";
|
|
43
|
+
private static final String API_PATH_SUFFIX = "/api/v1";
|
|
44
|
+
private static final String UPDATE_MODE_MANUAL = "manual";
|
|
45
|
+
private static final String UPDATE_MODE_NEXT_LAUNCH = "next-launch";
|
|
46
|
+
private static final String UPDATE_MODE_IMMEDIATE = "immediate";
|
|
47
|
+
|
|
48
|
+
@Override
|
|
49
|
+
public void load() {
|
|
50
|
+
super.load();
|
|
51
|
+
|
|
52
|
+
String builtinVersion = "0.0.0";
|
|
53
|
+
String nativeBuild = "1";
|
|
54
|
+
try {
|
|
55
|
+
PackageInfo info = getContext()
|
|
56
|
+
.getPackageManager()
|
|
57
|
+
.getPackageInfo(getContext().getPackageName(), 0);
|
|
58
|
+
builtinVersion = info.versionName != null ? info.versionName : builtinVersion;
|
|
59
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
60
|
+
nativeBuild = String.valueOf(info.getLongVersionCode());
|
|
61
|
+
} else {
|
|
62
|
+
//noinspection deprecation
|
|
63
|
+
nativeBuild = String.valueOf(info.versionCode);
|
|
64
|
+
}
|
|
65
|
+
} catch (PackageManager.NameNotFoundException ignored) {}
|
|
66
|
+
|
|
67
|
+
this.store = new BundleStore(getContext(), builtinVersion, nativeBuild);
|
|
68
|
+
this.updateUrl = resolveUpdateUrl(
|
|
69
|
+
getConfig().getString("serverUrl"),
|
|
70
|
+
System.getenv("OTAKIT_SERVER_URL")
|
|
71
|
+
);
|
|
72
|
+
this.appId = getConfig().getString("appId");
|
|
73
|
+
this.channel = trimToNull(getConfig().getString("channel"));
|
|
74
|
+
this.allowInsecureUrls = getConfig().getBoolean("allowInsecureUrls", false);
|
|
75
|
+
String configuredUpdateMode = getConfig().getString("updateMode", UPDATE_MODE_NEXT_LAUNCH);
|
|
76
|
+
this.updateMode = resolveUpdateMode(configuredUpdateMode);
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
org.json.JSONArray rawKeys = getConfig().getConfigJSON().optJSONArray("manifestKeys");
|
|
80
|
+
if (rawKeys != null && rawKeys.length() > 0) {
|
|
81
|
+
for (int i = 0; i < rawKeys.length(); i++) {
|
|
82
|
+
org.json.JSONObject entry = rawKeys.optJSONObject(i);
|
|
83
|
+
if (entry == null) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
String kid = entry.optString("kid", null);
|
|
87
|
+
String keyBase64 = entry.optString("key", null);
|
|
88
|
+
if (kid != null && keyBase64 != null) {
|
|
89
|
+
byte[] keyBytes = android.util.Base64.decode(keyBase64, android.util.Base64.DEFAULT);
|
|
90
|
+
manifestKeys.add(new ManifestVerifier.KeyEntry(kid, keyBytes));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (manifestKeys.isEmpty()) {
|
|
94
|
+
android.util.Log.e(
|
|
95
|
+
"UpdateKit",
|
|
96
|
+
"manifestKeys configured but all entries are invalid. Manifest verification will reject all updates."
|
|
97
|
+
);
|
|
98
|
+
manifestKeys.add(new ManifestVerifier.KeyEntry("_invalid_", new byte[0]));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch (Exception e) {
|
|
102
|
+
android.util.Log.e(
|
|
103
|
+
"UpdateKit",
|
|
104
|
+
"Failed to parse manifestKeys. Manifest verification will reject all updates.",
|
|
105
|
+
e
|
|
106
|
+
);
|
|
107
|
+
manifestKeys.add(new ManifestVerifier.KeyEntry("_invalid_", new byte[0]));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (manifestKeys.isEmpty() && HostedManifestKeys.matchesManagedServer(updateUrl)) {
|
|
111
|
+
manifestKeys.addAll(HostedManifestKeys.createDefaultKeys());
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.appReadyTimeoutMs = Math.max(1000, getConfig().getInt("appReadyTimeout", 10_000));
|
|
115
|
+
|
|
116
|
+
BundleInfo current = store.getCurrentBundle();
|
|
117
|
+
if (current.status == BundleStatus.TRIAL) {
|
|
118
|
+
rollbackCurrentBundle("app_restarted_before_notify");
|
|
119
|
+
current = store.getCurrentBundle();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (shouldActivateStagedOnLaunch()) {
|
|
123
|
+
current = activateStagedBundleForLaunch();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!current.isBuiltin() && current.path != null) {
|
|
127
|
+
applyServerBasePath(current.path);
|
|
128
|
+
} else {
|
|
129
|
+
applyServerBasePath(null);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (current.status == BundleStatus.PENDING) {
|
|
133
|
+
store.markStatus(current.id, BundleStatus.TRIAL);
|
|
134
|
+
scheduleTrialTimeout(current.id);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (isAutomaticUpdateMode()) {
|
|
138
|
+
startAutoUpdate();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private void startAutoUpdate() {
|
|
143
|
+
executor.execute(() -> {
|
|
144
|
+
try {
|
|
145
|
+
BundleInfo downloaded = performCheckAndDownload(null, true);
|
|
146
|
+
if (UPDATE_MODE_IMMEDIATE.equals(updateMode) && downloaded != null) {
|
|
147
|
+
activateStagedBundleForReload();
|
|
148
|
+
reloadWebView();
|
|
149
|
+
}
|
|
150
|
+
} catch (Exception e) {
|
|
151
|
+
if (UPDATE_MODE_IMMEDIATE.equals(updateMode)) {
|
|
152
|
+
android.util.Log.w("UpdateKit", "immediate startup update failed", e);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private boolean shouldActivateStagedOnLaunch() {
|
|
159
|
+
return !UPDATE_MODE_MANUAL.equals(updateMode);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private boolean isAutomaticUpdateMode() {
|
|
163
|
+
return !UPDATE_MODE_MANUAL.equals(updateMode);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private String resolveUpdateMode(String configuredUpdateMode) {
|
|
167
|
+
if (configuredUpdateMode == null) {
|
|
168
|
+
return UPDATE_MODE_NEXT_LAUNCH;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
String raw = configuredUpdateMode.trim().toLowerCase();
|
|
172
|
+
if (raw.isEmpty() || UPDATE_MODE_NEXT_LAUNCH.equals(raw)) {
|
|
173
|
+
return UPDATE_MODE_NEXT_LAUNCH;
|
|
174
|
+
}
|
|
175
|
+
if (UPDATE_MODE_MANUAL.equals(raw)) {
|
|
176
|
+
return UPDATE_MODE_MANUAL;
|
|
177
|
+
}
|
|
178
|
+
if (UPDATE_MODE_IMMEDIATE.equals(raw)) {
|
|
179
|
+
return UPDATE_MODE_IMMEDIATE;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
android.util.Log.w(
|
|
183
|
+
"UpdateKit",
|
|
184
|
+
"Unknown updateMode '" + raw + "', defaulting to 'next-launch'"
|
|
185
|
+
);
|
|
186
|
+
return UPDATE_MODE_NEXT_LAUNCH;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
@PluginMethod
|
|
190
|
+
public void debugGetState(PluginCall call) {
|
|
191
|
+
JSObject result = new JSObject();
|
|
192
|
+
result.put("current", store.getCurrentBundle().toJSObject());
|
|
193
|
+
result.put("fallback", store.getFallbackBundle().toJSObject());
|
|
194
|
+
result.put("builtinVersion", store.getBuiltinVersion());
|
|
195
|
+
|
|
196
|
+
String stagedId = store.getStagedBundleId();
|
|
197
|
+
if (stagedId != null) {
|
|
198
|
+
BundleInfo staged = store.getBundle(stagedId);
|
|
199
|
+
if (staged == null) {
|
|
200
|
+
store.setStagedBundleId(null);
|
|
201
|
+
}
|
|
202
|
+
result.put("staged", staged != null ? staged.toJSObject() : null);
|
|
203
|
+
} else {
|
|
204
|
+
result.put("staged", null);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
call.resolve(result);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
@PluginMethod
|
|
211
|
+
public void check(PluginCall call) {
|
|
212
|
+
String targetChannel = resolveTargetChannel(null);
|
|
213
|
+
executor.execute(() -> {
|
|
214
|
+
try {
|
|
215
|
+
ManifestClient.LatestManifest latest = fetchLatest(targetChannel);
|
|
216
|
+
if (latest == null) {
|
|
217
|
+
call.resolve((JSObject) null);
|
|
218
|
+
} else {
|
|
219
|
+
BundleInfo staged = findMatchingStagedBundle(latest, targetChannel);
|
|
220
|
+
call.resolve(manifestToJSObject(latest, staged != null));
|
|
221
|
+
}
|
|
222
|
+
} catch (Exception e) {
|
|
223
|
+
call.reject("check failed: " + e.getMessage());
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
@PluginMethod
|
|
229
|
+
public void download(PluginCall call) {
|
|
230
|
+
executor.execute(() -> {
|
|
231
|
+
try {
|
|
232
|
+
BundleInfo bundle = performCheckAndDownload(null, true);
|
|
233
|
+
if (bundle == null) {
|
|
234
|
+
call.resolve((JSObject) null);
|
|
235
|
+
} else {
|
|
236
|
+
call.resolve(bundle.toJSObject());
|
|
237
|
+
}
|
|
238
|
+
} catch (Exception e) {
|
|
239
|
+
call.reject("download failed: " + e.getMessage());
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@PluginMethod
|
|
245
|
+
public void debugCheck(PluginCall call) {
|
|
246
|
+
String requestedChannel = call.getString("channel");
|
|
247
|
+
String targetChannel = resolveTargetChannel(requestedChannel);
|
|
248
|
+
executor.execute(() -> {
|
|
249
|
+
try {
|
|
250
|
+
ManifestClient.LatestManifest latest = fetchLatest(targetChannel);
|
|
251
|
+
if (latest == null) {
|
|
252
|
+
call.resolve((JSObject) null);
|
|
253
|
+
} else {
|
|
254
|
+
BundleInfo staged = findMatchingStagedBundle(latest, targetChannel);
|
|
255
|
+
call.resolve(manifestToJSObject(latest, staged != null));
|
|
256
|
+
}
|
|
257
|
+
} catch (Exception e) {
|
|
258
|
+
call.reject("debugCheck failed: " + e.getMessage());
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
@PluginMethod
|
|
264
|
+
public void debugDownload(PluginCall call) {
|
|
265
|
+
String requestedChannel = call.getString("channel");
|
|
266
|
+
executor.execute(() -> {
|
|
267
|
+
try {
|
|
268
|
+
BundleInfo bundle = performCheckAndDownload(requestedChannel, true);
|
|
269
|
+
if (bundle == null) {
|
|
270
|
+
call.resolve((JSObject) null);
|
|
271
|
+
} else {
|
|
272
|
+
call.resolve(bundle.toJSObject());
|
|
273
|
+
}
|
|
274
|
+
} catch (Exception e) {
|
|
275
|
+
call.reject("debugDownload failed: " + e.getMessage());
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
@PluginMethod
|
|
281
|
+
public void apply(PluginCall call) {
|
|
282
|
+
String stagedId = store.getStagedBundleId();
|
|
283
|
+
if (stagedId == null) {
|
|
284
|
+
call.reject("No staged update to apply");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (!store.bundleExists(stagedId)) {
|
|
288
|
+
store.setStagedBundleId(null);
|
|
289
|
+
call.reject("Staged bundle not found");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
activateStagedBundleForReload();
|
|
294
|
+
call.resolve();
|
|
295
|
+
reloadWebView();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
@PluginMethod
|
|
299
|
+
public void notifyAppReady(PluginCall call) {
|
|
300
|
+
BundleInfo current = store.getCurrentBundle();
|
|
301
|
+
if (
|
|
302
|
+
!current.isBuiltin() &&
|
|
303
|
+
(current.status == BundleStatus.TRIAL || current.status == BundleStatus.PENDING)
|
|
304
|
+
) {
|
|
305
|
+
BundleInfo oldFallback = store.getFallbackBundle();
|
|
306
|
+
|
|
307
|
+
store.markStatus(current.id, BundleStatus.SUCCESS);
|
|
308
|
+
store.setFallbackBundleId(current.id);
|
|
309
|
+
BundleInfo updated = store.getBundle(current.id);
|
|
310
|
+
notifyListeners("appReady", updated != null ? updated.toJSObject() : current.toJSObject());
|
|
311
|
+
|
|
312
|
+
sendStats("applied", current.version, current.channel, current.releaseId, null);
|
|
313
|
+
|
|
314
|
+
if (!oldFallback.isBuiltin() && !oldFallback.id.equals(current.id)) {
|
|
315
|
+
try {
|
|
316
|
+
store.deleteBundle(oldFallback.id);
|
|
317
|
+
} catch (Exception ignored) {}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
call.resolve();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
@PluginMethod
|
|
325
|
+
public void debugReset(PluginCall call) {
|
|
326
|
+
cancelTrialTimeout();
|
|
327
|
+
store.setCurrentBundleId(null);
|
|
328
|
+
store.setStagedBundleId(null);
|
|
329
|
+
store.setFallbackBundleId(null);
|
|
330
|
+
store.setFailedBundle(null);
|
|
331
|
+
applyServerBasePath(null);
|
|
332
|
+
call.resolve();
|
|
333
|
+
reloadWebView();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
@PluginMethod
|
|
337
|
+
public void debugListBundles(PluginCall call) {
|
|
338
|
+
JSObject result = new JSObject();
|
|
339
|
+
JSArray bundles = store.listDownloadedBundles();
|
|
340
|
+
result.put("bundles", bundles);
|
|
341
|
+
call.resolve(result);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
@PluginMethod
|
|
345
|
+
public void debugDeleteBundle(PluginCall call) {
|
|
346
|
+
String bundleId = call.getString("bundleId");
|
|
347
|
+
if (bundleId == null) {
|
|
348
|
+
call.reject("Missing bundleId");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (!store.bundleExists(bundleId)) {
|
|
352
|
+
call.reject("Bundle not found");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
BundleInfo current = store.getCurrentBundle();
|
|
357
|
+
if (bundleId.equals(current.id)) {
|
|
358
|
+
call.reject("Cannot delete current bundle");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
BundleInfo fallback = store.getFallbackBundle();
|
|
363
|
+
if (bundleId.equals(fallback.id)) {
|
|
364
|
+
call.reject("Cannot delete fallback bundle");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
String stagedId = store.getStagedBundleId();
|
|
369
|
+
if (bundleId.equals(stagedId)) {
|
|
370
|
+
call.reject("Cannot delete staged bundle");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
store.deleteBundle(bundleId);
|
|
376
|
+
call.resolve();
|
|
377
|
+
} catch (Exception e) {
|
|
378
|
+
call.reject("Delete failed: " + e.getMessage());
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
@PluginMethod
|
|
383
|
+
public void debugGetLastFailure(PluginCall call) {
|
|
384
|
+
BundleInfo failed = store.getFailedBundle();
|
|
385
|
+
if (failed == null) {
|
|
386
|
+
call.resolve((JSObject) null);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
call.resolve(failed.toJSObject());
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private ManifestClient.LatestManifest fetchLatest(String channel) throws Exception {
|
|
393
|
+
if (appId == null || appId.trim().isEmpty()) {
|
|
394
|
+
throw new IllegalStateException("Missing appId in plugin config");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
BundleInfo current = store.getCurrentBundle();
|
|
398
|
+
|
|
399
|
+
return ManifestClient.fetchLatest(
|
|
400
|
+
updateUrl,
|
|
401
|
+
appId,
|
|
402
|
+
channel,
|
|
403
|
+
current.version,
|
|
404
|
+
current.releaseId,
|
|
405
|
+
store.getNativeBuild(),
|
|
406
|
+
"android",
|
|
407
|
+
allowInsecureUrls,
|
|
408
|
+
manifestKeys
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private BundleInfo performCheckAndDownload(String channel, boolean emitEvents) throws Exception {
|
|
413
|
+
String targetChannel = resolveTargetChannel(channel);
|
|
414
|
+
ManifestClient.LatestManifest latest = fetchLatest(targetChannel);
|
|
415
|
+
if (latest == null) {
|
|
416
|
+
if (emitEvents) {
|
|
417
|
+
notifyListeners("noUpdateAvailable", new JSObject());
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
BundleInfo staged = findMatchingStagedBundle(latest, targetChannel);
|
|
423
|
+
if (emitEvents) {
|
|
424
|
+
notifyListeners("updateAvailable", manifestToJSObject(latest, staged != null));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (staged != null) {
|
|
428
|
+
return staged;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
return downloadAndStage(
|
|
433
|
+
new URL(latest.url),
|
|
434
|
+
latest.version,
|
|
435
|
+
latest.sha256,
|
|
436
|
+
latest.size,
|
|
437
|
+
targetChannel,
|
|
438
|
+
latest.releaseId
|
|
439
|
+
);
|
|
440
|
+
} catch (Exception e) {
|
|
441
|
+
if (isExpiredURLError(e)) {
|
|
442
|
+
// Download URL may have expired — re-fetch manifest once and retry
|
|
443
|
+
ManifestClient.LatestManifest refreshed = fetchLatest(targetChannel);
|
|
444
|
+
if (refreshed == null) throw e;
|
|
445
|
+
return downloadAndStage(
|
|
446
|
+
new URL(refreshed.url),
|
|
447
|
+
refreshed.version,
|
|
448
|
+
refreshed.sha256,
|
|
449
|
+
refreshed.size,
|
|
450
|
+
targetChannel,
|
|
451
|
+
refreshed.releaseId
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
throw e;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private boolean isExpiredURLError(Exception e) {
|
|
459
|
+
String msg = e.getMessage();
|
|
460
|
+
if (msg == null) return false;
|
|
461
|
+
msg = msg.toLowerCase();
|
|
462
|
+
return (
|
|
463
|
+
msg.contains("403") ||
|
|
464
|
+
msg.contains("410") ||
|
|
465
|
+
msg.contains("forbidden") ||
|
|
466
|
+
msg.contains("expired")
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private BundleInfo downloadAndStage(URL url, String version, String expectedSha256)
|
|
471
|
+
throws Exception {
|
|
472
|
+
return downloadAndStage(url, version, expectedSha256, 0, null, null);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private BundleInfo downloadAndStage(
|
|
476
|
+
URL url,
|
|
477
|
+
String version,
|
|
478
|
+
String expectedSha256,
|
|
479
|
+
int expectedSize,
|
|
480
|
+
String channel,
|
|
481
|
+
String releaseId
|
|
482
|
+
) throws Exception {
|
|
483
|
+
// Check disk space before downloading
|
|
484
|
+
if (expectedSize > 0) {
|
|
485
|
+
long requiredSpace = (long) (expectedSize * 2.5); // zip + extracted + buffer
|
|
486
|
+
long availableSpace = getFreeDiskSpace();
|
|
487
|
+
if (availableSpace < requiredSpace) {
|
|
488
|
+
sendStats("download_error", version, channel, releaseId, "insufficient_disk_space");
|
|
489
|
+
throw new IllegalStateException("Insufficient disk space");
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
JSObject start = new JSObject();
|
|
494
|
+
start.put("version", version);
|
|
495
|
+
notifyListeners("downloadStarted", start);
|
|
496
|
+
|
|
497
|
+
File downloadedZip = null;
|
|
498
|
+
File extractedDirectory = null;
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
downloadedZip = downloadZip(url);
|
|
502
|
+
if (!HashUtils.verify(downloadedZip, expectedSha256)) {
|
|
503
|
+
throw new IllegalStateException("Downloaded bundle hash mismatch");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
extractedDirectory = new File(
|
|
507
|
+
getContext().getCacheDir(),
|
|
508
|
+
"updatekit-extract-" + System.currentTimeMillis()
|
|
509
|
+
);
|
|
510
|
+
if (!extractedDirectory.exists() && !extractedDirectory.mkdirs()) {
|
|
511
|
+
throw new IllegalStateException("Cannot create temporary extraction directory");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
zipUtils.extractSecurely(downloadedZip, extractedDirectory);
|
|
515
|
+
File bundleRoot = resolveBundleRoot(extractedDirectory);
|
|
516
|
+
|
|
517
|
+
String bundleId = buildBundleId(version);
|
|
518
|
+
File destination = store.bundleDirectory(bundleId);
|
|
519
|
+
if (destination.exists()) {
|
|
520
|
+
deleteRecursively(destination);
|
|
521
|
+
}
|
|
522
|
+
moveDirectory(bundleRoot, destination);
|
|
523
|
+
|
|
524
|
+
BundleInfo info = new BundleInfo(
|
|
525
|
+
bundleId,
|
|
526
|
+
version,
|
|
527
|
+
BundleStatus.PENDING,
|
|
528
|
+
System.currentTimeMillis(),
|
|
529
|
+
expectedSha256,
|
|
530
|
+
destination.getAbsolutePath(),
|
|
531
|
+
channel,
|
|
532
|
+
releaseId
|
|
533
|
+
);
|
|
534
|
+
String previousStagedId = store.getStagedBundleId();
|
|
535
|
+
store.saveBundle(info);
|
|
536
|
+
store.setStagedBundleId(bundleId);
|
|
537
|
+
cleanupSupersededStagedBundle(previousStagedId, bundleId);
|
|
538
|
+
|
|
539
|
+
notifyListeners("downloadComplete", info.toJSObject());
|
|
540
|
+
sendStats("downloaded", version, channel, releaseId, null);
|
|
541
|
+
return info;
|
|
542
|
+
} catch (Exception e) {
|
|
543
|
+
JSObject failed = new JSObject();
|
|
544
|
+
failed.put("version", version);
|
|
545
|
+
failed.put("error", e.getMessage());
|
|
546
|
+
notifyListeners("downloadFailed", failed);
|
|
547
|
+
sendStats("download_error", version, channel, releaseId, e.getMessage());
|
|
548
|
+
throw e;
|
|
549
|
+
} finally {
|
|
550
|
+
if (downloadedZip != null && downloadedZip.exists()) {
|
|
551
|
+
//noinspection ResultOfMethodCallIgnored
|
|
552
|
+
downloadedZip.delete();
|
|
553
|
+
}
|
|
554
|
+
if (extractedDirectory != null && extractedDirectory.exists()) {
|
|
555
|
+
try {
|
|
556
|
+
deleteRecursively(extractedDirectory);
|
|
557
|
+
} catch (Exception ignored) {}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private File downloadZip(URL url) throws Exception {
|
|
563
|
+
ManifestClient.requireHTTPS(url, allowInsecureUrls);
|
|
564
|
+
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
|
565
|
+
try {
|
|
566
|
+
connection.setRequestMethod("GET");
|
|
567
|
+
connection.setConnectTimeout(15_000);
|
|
568
|
+
connection.setReadTimeout(60_000);
|
|
569
|
+
|
|
570
|
+
int status = connection.getResponseCode();
|
|
571
|
+
if (status < 200 || status >= 300) {
|
|
572
|
+
throw new IllegalStateException("Download failed with HTTP " + status);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
File destination = File.createTempFile("updatekit-", ".zip", getContext().getCacheDir());
|
|
576
|
+
|
|
577
|
+
try (
|
|
578
|
+
InputStream input = connection.getInputStream();
|
|
579
|
+
FileOutputStream output = new FileOutputStream(destination)
|
|
580
|
+
) {
|
|
581
|
+
byte[] buffer = new byte[8192];
|
|
582
|
+
int read;
|
|
583
|
+
while ((read = input.read(buffer)) > 0) {
|
|
584
|
+
output.write(buffer, 0, read);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return destination;
|
|
589
|
+
} finally {
|
|
590
|
+
connection.disconnect();
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private File resolveBundleRoot(File extractedDirectory) throws Exception {
|
|
595
|
+
File rootIndex = new File(extractedDirectory, "index.html");
|
|
596
|
+
if (rootIndex.exists()) {
|
|
597
|
+
return extractedDirectory;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
File[] children = extractedDirectory.listFiles();
|
|
601
|
+
if (children != null && children.length == 1 && children[0].isDirectory()) {
|
|
602
|
+
File nestedIndex = new File(children[0], "index.html");
|
|
603
|
+
if (nestedIndex.exists()) {
|
|
604
|
+
return children[0];
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
throw new IllegalStateException("Bundle archive does not contain index.html");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private BundleInfo activateStagedBundleForLaunch() {
|
|
612
|
+
String stagedId = store.getStagedBundleId();
|
|
613
|
+
if (stagedId == null) {
|
|
614
|
+
return store.getCurrentBundle();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
BundleInfo staged = store.getBundle(stagedId);
|
|
618
|
+
if (staged == null) {
|
|
619
|
+
store.setStagedBundleId(null);
|
|
620
|
+
return store.getCurrentBundle();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
store.setCurrentBundleId(staged.id);
|
|
624
|
+
store.setStagedBundleId(null);
|
|
625
|
+
return staged;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private void activateStagedBundleForReload() {
|
|
629
|
+
String stagedId = store.getStagedBundleId();
|
|
630
|
+
if (stagedId == null) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
BundleInfo staged = store.getBundle(stagedId);
|
|
635
|
+
if (staged == null) {
|
|
636
|
+
store.setStagedBundleId(null);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
store.setCurrentBundleId(staged.id);
|
|
641
|
+
store.setStagedBundleId(null);
|
|
642
|
+
|
|
643
|
+
if (staged.status == BundleStatus.PENDING) {
|
|
644
|
+
store.markStatus(staged.id, BundleStatus.TRIAL);
|
|
645
|
+
staged = store.getBundle(staged.id);
|
|
646
|
+
}
|
|
647
|
+
if (staged != null && staged.status == BundleStatus.TRIAL) {
|
|
648
|
+
scheduleTrialTimeout(staged.id);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (staged != null && staged.path != null) {
|
|
652
|
+
applyServerBasePath(staged.path);
|
|
653
|
+
} else {
|
|
654
|
+
applyServerBasePath(null);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private void scheduleTrialTimeout(String bundleId) {
|
|
659
|
+
cancelTrialTimeout();
|
|
660
|
+
trialTimeoutRunnable = () -> {
|
|
661
|
+
BundleInfo current = store.getCurrentBundle();
|
|
662
|
+
if (current.id.equals(bundleId) && current.status == BundleStatus.TRIAL) {
|
|
663
|
+
rollbackCurrentBundle("notify_timeout");
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
mainHandler.postDelayed(trialTimeoutRunnable, appReadyTimeoutMs);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
private void cancelTrialTimeout() {
|
|
670
|
+
if (trialTimeoutRunnable != null) {
|
|
671
|
+
mainHandler.removeCallbacks(trialTimeoutRunnable);
|
|
672
|
+
trialTimeoutRunnable = null;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private void rollbackCurrentBundle(String reason) {
|
|
677
|
+
cancelTrialTimeout();
|
|
678
|
+
|
|
679
|
+
BundleInfo current = store.getCurrentBundle();
|
|
680
|
+
if (current.isBuiltin()) {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
BundleInfo failed = current.withStatus(BundleStatus.ERROR);
|
|
685
|
+
store.markStatus(current.id, BundleStatus.ERROR);
|
|
686
|
+
store.setFailedBundle(failed);
|
|
687
|
+
store.setStagedBundleId(null);
|
|
688
|
+
|
|
689
|
+
sendStats("rollback", current.version, current.channel, current.releaseId, reason);
|
|
690
|
+
|
|
691
|
+
BundleInfo fallback = store.getFallbackBundle();
|
|
692
|
+
JSObject payload = new JSObject();
|
|
693
|
+
payload.put("from", failed.toJSObject());
|
|
694
|
+
|
|
695
|
+
if (!fallback.isBuiltin() && fallback.path != null) {
|
|
696
|
+
store.setCurrentBundleId(fallback.id);
|
|
697
|
+
applyServerBasePath(fallback.path);
|
|
698
|
+
payload.put("to", fallback.toJSObject());
|
|
699
|
+
} else {
|
|
700
|
+
store.setCurrentBundleId(null);
|
|
701
|
+
applyServerBasePath(null);
|
|
702
|
+
payload.put("to", store.builtinBundle().toJSObject());
|
|
703
|
+
}
|
|
704
|
+
payload.put("reason", reason);
|
|
705
|
+
|
|
706
|
+
notifyListeners("rollback", payload);
|
|
707
|
+
|
|
708
|
+
try {
|
|
709
|
+
store.deleteBundle(current.id);
|
|
710
|
+
} catch (Exception ignored) {}
|
|
711
|
+
|
|
712
|
+
reloadWebView();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private void cleanupSupersededStagedBundle(String previousStagedId, String replacementId) {
|
|
716
|
+
if (
|
|
717
|
+
previousStagedId == null ||
|
|
718
|
+
previousStagedId.equals(replacementId) ||
|
|
719
|
+
"builtin".equals(previousStagedId)
|
|
720
|
+
) {
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
BundleInfo current = store.getCurrentBundle();
|
|
725
|
+
if (previousStagedId.equals(current.id)) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
BundleInfo fallback = store.getFallbackBundle();
|
|
730
|
+
if (previousStagedId.equals(fallback.id)) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
try {
|
|
735
|
+
store.deleteBundle(previousStagedId);
|
|
736
|
+
} catch (Exception ignored) {}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
private void applyServerBasePath(String path) {
|
|
740
|
+
mainHandler.post(() -> {
|
|
741
|
+
try {
|
|
742
|
+
if (path == null || path.isEmpty()) {
|
|
743
|
+
bridge.setServerBasePath("");
|
|
744
|
+
} else {
|
|
745
|
+
bridge.setServerBasePath(path);
|
|
746
|
+
}
|
|
747
|
+
} catch (Throwable ignored) {}
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private void reloadWebView() {
|
|
752
|
+
mainHandler.post(() -> {
|
|
753
|
+
if (bridge != null && bridge.getWebView() != null) {
|
|
754
|
+
bridge.getWebView().reload();
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private JSObject manifestToJSObject(ManifestClient.LatestManifest latest, boolean downloaded) {
|
|
760
|
+
JSObject object = new JSObject();
|
|
761
|
+
object.put("version", latest.version);
|
|
762
|
+
object.put("url", latest.url);
|
|
763
|
+
object.put("sha256", latest.sha256);
|
|
764
|
+
object.put("size", latest.size);
|
|
765
|
+
object.put("downloaded", downloaded);
|
|
766
|
+
if (latest.releaseId != null) {
|
|
767
|
+
object.put("releaseId", latest.releaseId);
|
|
768
|
+
}
|
|
769
|
+
if (latest.minNativeBuild != null) {
|
|
770
|
+
object.put("minNativeBuild", latest.minNativeBuild);
|
|
771
|
+
}
|
|
772
|
+
return object;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
private BundleInfo findMatchingStagedBundle(
|
|
776
|
+
ManifestClient.LatestManifest latest,
|
|
777
|
+
String targetChannel
|
|
778
|
+
) {
|
|
779
|
+
String stagedId = store.getStagedBundleId();
|
|
780
|
+
if (stagedId == null) {
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
BundleInfo staged = store.getBundle(stagedId);
|
|
785
|
+
if (staged == null) {
|
|
786
|
+
store.setStagedBundleId(null);
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (!java.util.Objects.equals(trimToNull(staged.channel), targetChannel)) {
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (
|
|
795
|
+
latest.releaseId != null &&
|
|
796
|
+
staged.releaseId != null &&
|
|
797
|
+
latest.releaseId.equals(staged.releaseId)
|
|
798
|
+
) {
|
|
799
|
+
return staged;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (
|
|
803
|
+
latest.version != null &&
|
|
804
|
+
latest.version.equals(staged.version) &&
|
|
805
|
+
latest.sha256 != null &&
|
|
806
|
+
latest.sha256.equals(staged.sha256)
|
|
807
|
+
) {
|
|
808
|
+
return staged;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private void moveDirectory(File source, File destination) throws Exception {
|
|
815
|
+
if (source.renameTo(destination)) {
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
copyRecursively(source, destination);
|
|
819
|
+
deleteRecursively(source);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private void copyRecursively(File source, File destination) throws Exception {
|
|
823
|
+
if (source.isDirectory()) {
|
|
824
|
+
if (!destination.exists() && !destination.mkdirs()) {
|
|
825
|
+
throw new IllegalStateException(
|
|
826
|
+
"Cannot create directory: " + destination.getAbsolutePath()
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
File[] children = source.listFiles();
|
|
830
|
+
if (children != null) {
|
|
831
|
+
for (File child : children) {
|
|
832
|
+
copyRecursively(child, new File(destination, child.getName()));
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
File parent = destination.getParentFile();
|
|
839
|
+
if (parent != null && !parent.exists() && !parent.mkdirs()) {
|
|
840
|
+
throw new IllegalStateException("Cannot create parent: " + parent.getAbsolutePath());
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
try (
|
|
844
|
+
FileInputStream input = new FileInputStream(source);
|
|
845
|
+
FileOutputStream output = new FileOutputStream(destination)
|
|
846
|
+
) {
|
|
847
|
+
byte[] buffer = new byte[8192];
|
|
848
|
+
int read;
|
|
849
|
+
while ((read = input.read(buffer)) > 0) {
|
|
850
|
+
output.write(buffer, 0, read);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private void deleteRecursively(File target) throws Exception {
|
|
856
|
+
if (!target.exists()) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
if (target.isDirectory()) {
|
|
860
|
+
File[] children = target.listFiles();
|
|
861
|
+
if (children != null) {
|
|
862
|
+
for (File child : children) {
|
|
863
|
+
deleteRecursively(child);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (!target.delete()) {
|
|
868
|
+
throw new IllegalStateException("Failed to delete: " + target.getAbsolutePath());
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private String buildBundleId(String version) throws Exception {
|
|
873
|
+
String trimmed = version == null ? "" : version.trim();
|
|
874
|
+
String normalized = trimmed.replaceAll("[^A-Za-z0-9._-]", "-");
|
|
875
|
+
normalized = normalized.replaceAll("-{2,}", "-");
|
|
876
|
+
normalized = normalized.replaceAll("^[\\-.]+|[\\-.]+$", "");
|
|
877
|
+
if (normalized.isEmpty()) {
|
|
878
|
+
normalized = "bundle";
|
|
879
|
+
}
|
|
880
|
+
if (normalized.length() > 64) {
|
|
881
|
+
normalized = normalized.substring(0, 64);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
885
|
+
byte[] hash = digest.digest(trimmed.getBytes(StandardCharsets.UTF_8));
|
|
886
|
+
StringBuilder suffix = new StringBuilder();
|
|
887
|
+
for (int i = 0; i < 6; i++) {
|
|
888
|
+
suffix.append(String.format("%02x", hash[i]));
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
return normalized + "-" + suffix;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private String resolveUpdateUrl(String configured, String env) {
|
|
895
|
+
String configuredValue = trimToNull(configured);
|
|
896
|
+
if (configuredValue != null) {
|
|
897
|
+
return normalizeUpdateUrl(configuredValue);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
String envValue = trimToNull(env);
|
|
901
|
+
if (envValue != null) {
|
|
902
|
+
return normalizeUpdateUrl(envValue);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return DEFAULT_UPDATE_URL;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
private String normalizeUpdateUrl(String raw) {
|
|
909
|
+
String trimmed = raw.trim().replaceAll("/+$", "");
|
|
910
|
+
if (trimmed.toLowerCase(java.util.Locale.ROOT).endsWith(API_PATH_SUFFIX)) {
|
|
911
|
+
return trimmed;
|
|
912
|
+
}
|
|
913
|
+
return trimmed + API_PATH_SUFFIX;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
private String trimToNull(String value) {
|
|
917
|
+
if (value == null) {
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
String trimmed = value.trim();
|
|
922
|
+
return trimmed.isEmpty() ? null : trimmed;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
private String resolveTargetChannel(String channel) {
|
|
926
|
+
String resolved = trimToNull(channel);
|
|
927
|
+
return resolved != null ? resolved : this.channel;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
private void sendStats(
|
|
931
|
+
String action,
|
|
932
|
+
String bundleVersion,
|
|
933
|
+
String channel,
|
|
934
|
+
String releaseId,
|
|
935
|
+
String errorMessage
|
|
936
|
+
) {
|
|
937
|
+
if (appId == null) {
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
StatsClient.send(
|
|
941
|
+
updateUrl,
|
|
942
|
+
appId,
|
|
943
|
+
"android",
|
|
944
|
+
action,
|
|
945
|
+
bundleVersion,
|
|
946
|
+
channel,
|
|
947
|
+
releaseId,
|
|
948
|
+
store.getNativeBuild(),
|
|
949
|
+
errorMessage
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
private long getFreeDiskSpace() {
|
|
954
|
+
try {
|
|
955
|
+
android.os.StatFs statFs = new android.os.StatFs(
|
|
956
|
+
getContext().getFilesDir().getAbsolutePath()
|
|
957
|
+
);
|
|
958
|
+
return statFs.getAvailableBlocksLong() * statFs.getBlockSizeLong();
|
|
959
|
+
} catch (Exception e) {
|
|
960
|
+
return Long.MAX_VALUE; // If we can't determine, allow download
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|