@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,32 @@
|
|
|
1
|
+
package com.updatekit.updater;
|
|
2
|
+
|
|
3
|
+
import java.io.File;
|
|
4
|
+
import java.io.FileInputStream;
|
|
5
|
+
import java.security.MessageDigest;
|
|
6
|
+
|
|
7
|
+
final class HashUtils {
|
|
8
|
+
|
|
9
|
+
private HashUtils() {}
|
|
10
|
+
|
|
11
|
+
static String sha256(File file) throws Exception {
|
|
12
|
+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
13
|
+
try (FileInputStream input = new FileInputStream(file)) {
|
|
14
|
+
byte[] buffer = new byte[1024 * 1024];
|
|
15
|
+
int read;
|
|
16
|
+
while ((read = input.read(buffer)) > 0) {
|
|
17
|
+
digest.update(buffer, 0, read);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
byte[] hash = digest.digest();
|
|
22
|
+
StringBuilder builder = new StringBuilder();
|
|
23
|
+
for (byte b : hash) {
|
|
24
|
+
builder.append(String.format("%02x", b));
|
|
25
|
+
}
|
|
26
|
+
return builder.toString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static boolean verify(File file, String expectedSha256) throws Exception {
|
|
30
|
+
return sha256(file).equalsIgnoreCase(expectedSha256);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
package com.updatekit.updater;
|
|
2
|
+
|
|
3
|
+
import android.util.Base64;
|
|
4
|
+
import java.util.ArrayList;
|
|
5
|
+
import java.util.List;
|
|
6
|
+
import java.util.Locale;
|
|
7
|
+
|
|
8
|
+
final class HostedManifestKeys {
|
|
9
|
+
|
|
10
|
+
private static final String MANAGED_SERVER_URL = "https://www.otakit.app/api/v1";
|
|
11
|
+
|
|
12
|
+
private HostedManifestKeys() {}
|
|
13
|
+
|
|
14
|
+
static boolean matchesManagedServer(String updateUrl) {
|
|
15
|
+
if (updateUrl == null) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
String normalized = updateUrl.trim().replaceAll("/+$", "").toLowerCase(Locale.ROOT);
|
|
20
|
+
return normalized.equals(MANAGED_SERVER_URL)
|
|
21
|
+
|| normalized.equals("https://otakit.app/api/v1");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static List<ManifestVerifier.KeyEntry> createDefaultKeys() {
|
|
25
|
+
ArrayList<ManifestVerifier.KeyEntry> keys = new ArrayList<>();
|
|
26
|
+
keys.add(
|
|
27
|
+
new ManifestVerifier.KeyEntry(
|
|
28
|
+
"hosted-2026-04-02-ce611e6d",
|
|
29
|
+
Base64.decode(
|
|
30
|
+
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELg6eAj2+7aZ1FJnYUNMjOtWuQLJMomXkPvmeTQ3gXyabpLTDX0m3iWYO3cEOXqIR6NphGC6csS2T5bCtXwIBFw==",
|
|
31
|
+
Base64.DEFAULT
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
);
|
|
35
|
+
return keys;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
package com.updatekit.updater;
|
|
2
|
+
|
|
3
|
+
import java.io.ByteArrayOutputStream;
|
|
4
|
+
import java.io.InputStream;
|
|
5
|
+
import java.net.HttpURLConnection;
|
|
6
|
+
import java.net.URL;
|
|
7
|
+
import java.nio.charset.StandardCharsets;
|
|
8
|
+
import org.json.JSONObject;
|
|
9
|
+
|
|
10
|
+
final class ManifestClient {
|
|
11
|
+
|
|
12
|
+
static final class ManifestSignature {
|
|
13
|
+
|
|
14
|
+
final String kid;
|
|
15
|
+
final String sig;
|
|
16
|
+
final int iat;
|
|
17
|
+
final int exp;
|
|
18
|
+
|
|
19
|
+
ManifestSignature(String kid, String sig, int iat, int exp) {
|
|
20
|
+
this.kid = kid;
|
|
21
|
+
this.sig = sig;
|
|
22
|
+
this.iat = iat;
|
|
23
|
+
this.exp = exp;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static final class LatestManifest {
|
|
28
|
+
|
|
29
|
+
final String version;
|
|
30
|
+
final String url;
|
|
31
|
+
final String sha256;
|
|
32
|
+
final int size;
|
|
33
|
+
final Integer minNativeBuild;
|
|
34
|
+
final String releaseId;
|
|
35
|
+
final ManifestSignature signature;
|
|
36
|
+
|
|
37
|
+
LatestManifest(
|
|
38
|
+
String version,
|
|
39
|
+
String url,
|
|
40
|
+
String sha256,
|
|
41
|
+
int size,
|
|
42
|
+
Integer minNativeBuild,
|
|
43
|
+
String releaseId,
|
|
44
|
+
ManifestSignature signature
|
|
45
|
+
) {
|
|
46
|
+
this.version = version;
|
|
47
|
+
this.url = url;
|
|
48
|
+
this.sha256 = sha256;
|
|
49
|
+
this.size = size;
|
|
50
|
+
this.minNativeBuild = minNativeBuild;
|
|
51
|
+
this.releaseId = releaseId;
|
|
52
|
+
this.signature = signature;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private ManifestClient() {}
|
|
57
|
+
|
|
58
|
+
static void requireHTTPS(URL url, boolean allowInsecure) throws Exception {
|
|
59
|
+
String protocol = url.getProtocol().toLowerCase();
|
|
60
|
+
if ("https".equals(protocol)) return;
|
|
61
|
+
if (allowInsecure) {
|
|
62
|
+
String host = url.getHost().toLowerCase();
|
|
63
|
+
if ("localhost".equals(host) || "127.0.0.1".equals(host)) return;
|
|
64
|
+
}
|
|
65
|
+
throw new IllegalStateException("URL must use HTTPS: " + url.toString());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static LatestManifest fetchLatest(
|
|
69
|
+
String updateUrl,
|
|
70
|
+
String appId,
|
|
71
|
+
String channel,
|
|
72
|
+
String currentVersion,
|
|
73
|
+
String currentReleaseId,
|
|
74
|
+
String nativeBuild,
|
|
75
|
+
String platform,
|
|
76
|
+
boolean allowInsecureUrls,
|
|
77
|
+
java.util.List<ManifestVerifier.KeyEntry> manifestKeys
|
|
78
|
+
) throws Exception {
|
|
79
|
+
String base = updateUrl.replaceAll("/+$", "");
|
|
80
|
+
URL url = new URL(base + "/manifest");
|
|
81
|
+
|
|
82
|
+
requireHTTPS(url, allowInsecureUrls);
|
|
83
|
+
|
|
84
|
+
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
|
85
|
+
try {
|
|
86
|
+
connection.setRequestMethod("GET");
|
|
87
|
+
connection.setRequestProperty("X-App-Id", appId);
|
|
88
|
+
connection.setRequestProperty("X-Platform", platform);
|
|
89
|
+
if (channel != null && !channel.trim().isEmpty()) {
|
|
90
|
+
connection.setRequestProperty("X-Channel", channel);
|
|
91
|
+
}
|
|
92
|
+
connection.setRequestProperty("X-Current-Version", currentVersion);
|
|
93
|
+
if (currentReleaseId != null && !currentReleaseId.trim().isEmpty()) {
|
|
94
|
+
connection.setRequestProperty("X-Release-Id", currentReleaseId);
|
|
95
|
+
}
|
|
96
|
+
connection.setRequestProperty("X-Native-Build", nativeBuild);
|
|
97
|
+
connection.setConnectTimeout(15_000);
|
|
98
|
+
connection.setReadTimeout(30_000);
|
|
99
|
+
|
|
100
|
+
int status = connection.getResponseCode();
|
|
101
|
+
if (status == 204) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (status != 200) {
|
|
105
|
+
String body = readStream(
|
|
106
|
+
connection.getErrorStream() != null
|
|
107
|
+
? connection.getErrorStream()
|
|
108
|
+
: connection.getInputStream()
|
|
109
|
+
);
|
|
110
|
+
throw new IllegalStateException("Latest request failed (" + status + "): " + body);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
String payload = readStream(connection.getInputStream());
|
|
114
|
+
JSONObject json = new JSONObject(payload);
|
|
115
|
+
|
|
116
|
+
String version = json.getString("version");
|
|
117
|
+
String downloadUrl = json.getString("url");
|
|
118
|
+
String sha256 = json.getString("sha256");
|
|
119
|
+
int size = json.getInt("size");
|
|
120
|
+
|
|
121
|
+
Integer minNativeBuild = null;
|
|
122
|
+
if (json.has("minNativeBuild") && !json.isNull("minNativeBuild")) {
|
|
123
|
+
Object raw = json.get("minNativeBuild");
|
|
124
|
+
if (raw instanceof Number) {
|
|
125
|
+
minNativeBuild = ((Number) raw).intValue();
|
|
126
|
+
} else if (raw instanceof String) {
|
|
127
|
+
String value = ((String) raw).trim();
|
|
128
|
+
if (!value.isEmpty()) {
|
|
129
|
+
minNativeBuild = Integer.parseInt(value);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
ManifestSignature signature = null;
|
|
135
|
+
if (json.has("signature") && !json.isNull("signature")) {
|
|
136
|
+
JSONObject sigObj = json.getJSONObject("signature");
|
|
137
|
+
if (sigObj.has("kid") && sigObj.has("sig") && sigObj.has("iat") && sigObj.has("exp")) {
|
|
138
|
+
signature = new ManifestSignature(
|
|
139
|
+
sigObj.getString("kid"),
|
|
140
|
+
sigObj.getString("sig"),
|
|
141
|
+
sigObj.getInt("iat"),
|
|
142
|
+
sigObj.getInt("exp")
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
String releaseId = null;
|
|
148
|
+
if (json.has("releaseId") && !json.isNull("releaseId")) {
|
|
149
|
+
releaseId = json.getString("releaseId");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Validate download URL scheme
|
|
153
|
+
requireHTTPS(new URL(downloadUrl), allowInsecureUrls);
|
|
154
|
+
|
|
155
|
+
if (manifestKeys == null || manifestKeys.isEmpty()) {
|
|
156
|
+
android.util.Log.w(
|
|
157
|
+
"UpdateKit",
|
|
158
|
+
"No manifest signing keys configured — signature verification is disabled for this request."
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Verify manifest signature if signing keys are configured
|
|
163
|
+
if (manifestKeys != null && !manifestKeys.isEmpty()) {
|
|
164
|
+
if (signature == null) {
|
|
165
|
+
throw new IllegalStateException(
|
|
166
|
+
"Manifest signature missing but signing keys are configured"
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
ManifestVerifier.verify(
|
|
170
|
+
appId,
|
|
171
|
+
channel,
|
|
172
|
+
platform,
|
|
173
|
+
version,
|
|
174
|
+
sha256,
|
|
175
|
+
size,
|
|
176
|
+
minNativeBuild,
|
|
177
|
+
signature,
|
|
178
|
+
manifestKeys
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return new LatestManifest(
|
|
183
|
+
version,
|
|
184
|
+
downloadUrl,
|
|
185
|
+
sha256,
|
|
186
|
+
size,
|
|
187
|
+
minNativeBuild,
|
|
188
|
+
releaseId,
|
|
189
|
+
signature
|
|
190
|
+
);
|
|
191
|
+
} finally {
|
|
192
|
+
connection.disconnect();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private static String readStream(InputStream input) throws Exception {
|
|
197
|
+
if (input == null) {
|
|
198
|
+
return "";
|
|
199
|
+
}
|
|
200
|
+
try (InputStream stream = input; ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
|
201
|
+
byte[] buffer = new byte[8192];
|
|
202
|
+
int read;
|
|
203
|
+
while ((read = stream.read(buffer)) > 0) {
|
|
204
|
+
out.write(buffer, 0, read);
|
|
205
|
+
}
|
|
206
|
+
return new String(out.toByteArray(), StandardCharsets.UTF_8);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
package com.updatekit.updater;
|
|
2
|
+
|
|
3
|
+
import android.util.Base64;
|
|
4
|
+
import java.security.KeyFactory;
|
|
5
|
+
import java.security.PublicKey;
|
|
6
|
+
import java.security.Signature;
|
|
7
|
+
import java.security.spec.X509EncodedKeySpec;
|
|
8
|
+
import java.util.List;
|
|
9
|
+
|
|
10
|
+
final class ManifestVerifier {
|
|
11
|
+
|
|
12
|
+
static final class KeyEntry {
|
|
13
|
+
|
|
14
|
+
final String kid;
|
|
15
|
+
final byte[] derData;
|
|
16
|
+
|
|
17
|
+
KeyEntry(String kid, byte[] derData) {
|
|
18
|
+
this.kid = kid;
|
|
19
|
+
this.derData = derData;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private ManifestVerifier() {}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Verify a manifest signature using ES256 (ECDSA P-256 + SHA-256).
|
|
27
|
+
*
|
|
28
|
+
* @throws Exception on verification failure (unknown kid, expired, invalid signature).
|
|
29
|
+
*/
|
|
30
|
+
static void verify(
|
|
31
|
+
String appId,
|
|
32
|
+
String channel,
|
|
33
|
+
String platform,
|
|
34
|
+
String version,
|
|
35
|
+
String sha256,
|
|
36
|
+
int size,
|
|
37
|
+
Integer minNativeBuild,
|
|
38
|
+
ManifestClient.ManifestSignature signature,
|
|
39
|
+
List<KeyEntry> trustedKeys
|
|
40
|
+
) throws Exception {
|
|
41
|
+
// Check expiry
|
|
42
|
+
long now = System.currentTimeMillis() / 1000;
|
|
43
|
+
if (signature.exp <= now) {
|
|
44
|
+
throw new IllegalStateException("Manifest signature expired");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Find matching key
|
|
48
|
+
KeyEntry keyEntry = null;
|
|
49
|
+
for (KeyEntry entry : trustedKeys) {
|
|
50
|
+
if (entry.kid.equals(signature.kid)) {
|
|
51
|
+
keyEntry = entry;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (keyEntry == null) {
|
|
56
|
+
throw new IllegalStateException("Unknown signing key ID: " + signature.kid);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Build canonical payload (must match server exactly)
|
|
60
|
+
String payload = buildCanonicalPayload(
|
|
61
|
+
appId,
|
|
62
|
+
channel,
|
|
63
|
+
platform,
|
|
64
|
+
version,
|
|
65
|
+
sha256,
|
|
66
|
+
size,
|
|
67
|
+
minNativeBuild,
|
|
68
|
+
signature.kid,
|
|
69
|
+
signature.iat,
|
|
70
|
+
signature.exp
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Decode base64url signature
|
|
74
|
+
byte[] sigBytes = base64UrlDecode(signature.sig);
|
|
75
|
+
|
|
76
|
+
// Verify with java.security
|
|
77
|
+
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyEntry.derData);
|
|
78
|
+
KeyFactory keyFactory = KeyFactory.getInstance("EC");
|
|
79
|
+
PublicKey verificationKey = keyFactory.generatePublic(keySpec);
|
|
80
|
+
|
|
81
|
+
Signature verifier = Signature.getInstance("SHA256withECDSA");
|
|
82
|
+
verifier.initVerify(verificationKey);
|
|
83
|
+
verifier.update(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
|
84
|
+
|
|
85
|
+
if (!verifier.verify(sigBytes)) {
|
|
86
|
+
throw new IllegalStateException("Manifest signature verification failed");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private static String buildCanonicalPayload(
|
|
91
|
+
String appId,
|
|
92
|
+
String channel,
|
|
93
|
+
String platform,
|
|
94
|
+
String version,
|
|
95
|
+
String sha256,
|
|
96
|
+
int size,
|
|
97
|
+
Integer minNativeBuild,
|
|
98
|
+
String kid,
|
|
99
|
+
int iat,
|
|
100
|
+
int exp
|
|
101
|
+
) {
|
|
102
|
+
String minBuildStr = minNativeBuild != null ? String.valueOf(minNativeBuild) : "null";
|
|
103
|
+
return (
|
|
104
|
+
"MANIFEST_V1\n" +
|
|
105
|
+
"appId:" +
|
|
106
|
+
appId +
|
|
107
|
+
"\n" +
|
|
108
|
+
"channel:" +
|
|
109
|
+
(channel != null ? channel : "null") +
|
|
110
|
+
"\n" +
|
|
111
|
+
"platform:" +
|
|
112
|
+
platform +
|
|
113
|
+
"\n" +
|
|
114
|
+
"version:" +
|
|
115
|
+
version +
|
|
116
|
+
"\n" +
|
|
117
|
+
"sha256:" +
|
|
118
|
+
sha256 +
|
|
119
|
+
"\n" +
|
|
120
|
+
"size:" +
|
|
121
|
+
size +
|
|
122
|
+
"\n" +
|
|
123
|
+
"minNativeBuild:" +
|
|
124
|
+
minBuildStr +
|
|
125
|
+
"\n" +
|
|
126
|
+
"kid:" +
|
|
127
|
+
kid +
|
|
128
|
+
"\n" +
|
|
129
|
+
"iat:" +
|
|
130
|
+
iat +
|
|
131
|
+
"\n" +
|
|
132
|
+
"exp:" +
|
|
133
|
+
exp
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private static byte[] base64UrlDecode(String input) {
|
|
138
|
+
// Convert base64url to standard base64
|
|
139
|
+
String base64 = input.replace('-', '+').replace('_', '/');
|
|
140
|
+
int remainder = base64.length() % 4;
|
|
141
|
+
if (remainder > 0) {
|
|
142
|
+
base64 += "====".substring(remainder);
|
|
143
|
+
}
|
|
144
|
+
return Base64.decode(base64, Base64.DEFAULT);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
package com.updatekit.updater;
|
|
2
|
+
|
|
3
|
+
import java.io.OutputStream;
|
|
4
|
+
import java.net.HttpURLConnection;
|
|
5
|
+
import java.net.URL;
|
|
6
|
+
import java.nio.charset.StandardCharsets;
|
|
7
|
+
import java.util.concurrent.ExecutorService;
|
|
8
|
+
import java.util.concurrent.Executors;
|
|
9
|
+
import org.json.JSONObject;
|
|
10
|
+
|
|
11
|
+
final class StatsClient {
|
|
12
|
+
|
|
13
|
+
private static final ExecutorService executor = Executors.newSingleThreadExecutor();
|
|
14
|
+
|
|
15
|
+
private StatsClient() {}
|
|
16
|
+
|
|
17
|
+
static void send(
|
|
18
|
+
String updateUrl,
|
|
19
|
+
String appId,
|
|
20
|
+
String platform,
|
|
21
|
+
String action,
|
|
22
|
+
String bundleVersion,
|
|
23
|
+
String channel,
|
|
24
|
+
String releaseId,
|
|
25
|
+
String nativeBuild,
|
|
26
|
+
String errorMessage
|
|
27
|
+
) {
|
|
28
|
+
executor.execute(() -> {
|
|
29
|
+
HttpURLConnection connection = null;
|
|
30
|
+
try {
|
|
31
|
+
String base = updateUrl.replaceAll("/+$", "");
|
|
32
|
+
URL url = new URL(base + "/stats");
|
|
33
|
+
|
|
34
|
+
JSONObject payload = new JSONObject();
|
|
35
|
+
payload.put("platform", platform);
|
|
36
|
+
payload.put("action", action);
|
|
37
|
+
if (bundleVersion != null) {
|
|
38
|
+
payload.put("bundleVersion", bundleVersion);
|
|
39
|
+
}
|
|
40
|
+
if (channel != null && !channel.isEmpty()) {
|
|
41
|
+
payload.put("channel", channel);
|
|
42
|
+
}
|
|
43
|
+
if (releaseId != null && !releaseId.isEmpty()) {
|
|
44
|
+
payload.put("releaseId", releaseId);
|
|
45
|
+
}
|
|
46
|
+
if (nativeBuild != null) {
|
|
47
|
+
payload.put("nativeBuild", nativeBuild);
|
|
48
|
+
}
|
|
49
|
+
if (errorMessage != null) {
|
|
50
|
+
String truncated =
|
|
51
|
+
errorMessage.length() > 500 ? errorMessage.substring(0, 500) : errorMessage;
|
|
52
|
+
payload.put("errorMessage", truncated);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
byte[] body = payload.toString().getBytes(StandardCharsets.UTF_8);
|
|
56
|
+
|
|
57
|
+
connection = (HttpURLConnection) url.openConnection();
|
|
58
|
+
connection.setRequestMethod("POST");
|
|
59
|
+
connection.setRequestProperty("X-App-Id", appId);
|
|
60
|
+
connection.setRequestProperty("Content-Type", "application/json");
|
|
61
|
+
connection.setConnectTimeout(10_000);
|
|
62
|
+
connection.setReadTimeout(10_000);
|
|
63
|
+
connection.setDoOutput(true);
|
|
64
|
+
|
|
65
|
+
try (OutputStream output = connection.getOutputStream()) {
|
|
66
|
+
output.write(body);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Fire and forget - just trigger the request
|
|
70
|
+
connection.getResponseCode();
|
|
71
|
+
} catch (Exception ignored) {
|
|
72
|
+
// Stats are best-effort, don't fail on errors
|
|
73
|
+
} finally {
|
|
74
|
+
if (connection != null) {
|
|
75
|
+
connection.disconnect();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|