@capgo/capacitor-updater 6.43.4 → 6.45.10

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.
@@ -81,9 +81,7 @@ public class CapgoUpdater {
81
81
  public String channelUrl = "";
82
82
  public String defaultChannel = "";
83
83
  public String appId = "";
84
- public String privateKey = "";
85
84
  public String publicKey = "";
86
- public boolean hasOldPrivateKeyPropertyInConfig = false;
87
85
  public String deviceID = "";
88
86
  public int timeout = 20000;
89
87
 
@@ -347,7 +345,7 @@ public class CapgoUpdater {
347
345
  }
348
346
  }
349
347
 
350
- private void observeWorkProgress(Context context, String id) {
348
+ private void observeWorkProgress(Context context, String id, boolean setNext) {
351
349
  if (!(context instanceof LifecycleOwner)) {
352
350
  logger.error("Context is not a LifecycleOwner, cannot observe work progress");
353
351
  return;
@@ -377,7 +375,7 @@ public class CapgoUpdater {
377
375
  boolean isManifest = outputData.getBoolean(DownloadService.IS_MANIFEST, false);
378
376
 
379
377
  io.execute(() -> {
380
- boolean success = finishDownload(id, dest, version, sessionKey, checksum, true, isManifest);
378
+ boolean success = finishDownload(id, dest, version, sessionKey, checksum, setNext, isManifest);
381
379
  BundleInfo resultBundle;
382
380
  if (!success) {
383
381
  logger.error("Finish download failed");
@@ -456,13 +454,14 @@ public class CapgoUpdater {
456
454
  final String version,
457
455
  final String sessionKey,
458
456
  final String checksum,
459
- final JSONArray manifest
457
+ final JSONArray manifest,
458
+ final boolean setNext
460
459
  ) {
461
460
  if (this.activity == null) {
462
461
  logger.error("Activity is null, cannot observe work progress");
463
462
  return;
464
463
  }
465
- observeWorkProgress(this.activity, id);
464
+ observeWorkProgress(this.activity, id, setNext);
466
465
 
467
466
  DownloadWorkerManager.enqueueDownload(
468
467
  this.activity,
@@ -520,15 +519,12 @@ public class CapgoUpdater {
520
519
  throw new IOException("Checksum required when public key is present: " + id);
521
520
  }
522
521
 
523
- if (!this.hasOldPrivateKeyPropertyInConfig && !sessionKey.isEmpty()) {
524
- // V2 Encryption (publicKey)
522
+ if (!sessionKey.isEmpty()) {
525
523
  CryptoCipher.decryptFile(downloaded, publicKey, sessionKey);
526
524
  checksumDecrypted = CryptoCipher.decryptChecksum(checksumRes, publicKey);
527
525
  checksum = CryptoCipher.calcChecksum(downloaded);
528
- } else if (this.hasOldPrivateKeyPropertyInConfig) {
529
- // V1 Encryption (privateKey) - deprecated not supported
530
- this.sendStats("checksum_fail");
531
- throw new IOException("V1 decryption is no longer supported for security reasons.");
526
+ } else {
527
+ checksum = CryptoCipher.calcChecksum(downloaded);
532
528
  }
533
529
  CryptoCipher.logChecksumInfo("Calculated checksum", checksum);
534
530
  CryptoCipher.logChecksumInfo("Expected checksum", checksumDecrypted);
@@ -762,12 +758,37 @@ public class CapgoUpdater {
762
758
  this.editor.commit();
763
759
  }
764
760
 
761
+ static boolean shouldResetForForeignBundle(final String bundlePath, final boolean isBuiltin, final boolean hasStoredBundleInfo) {
762
+ return bundlePath != null && !bundlePath.trim().isEmpty() && !isBuiltin && !hasStoredBundleInfo;
763
+ }
764
+
765
+ private boolean hasStoredBundleInfo(final String id) {
766
+ return (
767
+ id != null &&
768
+ !id.isEmpty() &&
769
+ !BundleInfo.ID_BUILTIN.equals(id) &&
770
+ !BundleInfo.VERSION_UNKNOWN.equals(id) &&
771
+ this.prefs.contains(id + INFO_SUFFIX)
772
+ );
773
+ }
774
+
765
775
  public void downloadBackground(
766
776
  final String url,
767
777
  final String version,
768
778
  final String sessionKey,
769
779
  final String checksum,
770
780
  final JSONArray manifest
781
+ ) {
782
+ downloadBackground(url, version, sessionKey, checksum, manifest, true);
783
+ }
784
+
785
+ public void downloadBackground(
786
+ final String url,
787
+ final String version,
788
+ final String sessionKey,
789
+ final String checksum,
790
+ final JSONArray manifest,
791
+ final boolean setNext
771
792
  ) {
772
793
  final String id = this.randomString();
773
794
 
@@ -789,7 +810,7 @@ public class CapgoUpdater {
789
810
  this.notifyDownload(id, 0);
790
811
  this.notifyDownload(id, 5);
791
812
 
792
- this.download(id, url, this.randomString(), version, sessionKey, checksum, manifest);
813
+ this.download(id, url, this.randomString(), version, sessionKey, checksum, manifest, setNext);
793
814
  }
794
815
 
795
816
  public BundleInfo download(final String url, final String version, final String sessionKey, final String checksum) throws IOException {
@@ -811,7 +832,7 @@ public class CapgoUpdater {
811
832
  downloadFutures.put(id, downloadFuture);
812
833
 
813
834
  // Start the download
814
- this.download(id, url, dest, version, sessionKey, checksum, null);
835
+ this.download(id, url, dest, version, sessionKey, checksum, null, false);
815
836
 
816
837
  // Wait for completion without timeout
817
838
  try {
@@ -863,7 +884,7 @@ public class CapgoUpdater {
863
884
  downloadFutures.put(id, downloadFuture);
864
885
 
865
886
  // Start the download
866
- this.download(id, url, dest, version, sessionKey, checksum, manifest);
887
+ this.download(id, url, dest, version, sessionKey, checksum, manifest, false);
867
888
 
868
889
  // Wait for completion without timeout
869
890
  try {
@@ -970,6 +991,64 @@ public class CapgoUpdater {
970
991
  return (bundle.isDirectory() && bundle.exists() && new File(bundle.getPath(), "/index.html").exists() && !bundleInfo.isDeleted());
971
992
  }
972
993
 
994
+ static final class ResetState {
995
+
996
+ final String currentBundlePath;
997
+ final String fallbackBundleId;
998
+ final String nextBundleId;
999
+
1000
+ ResetState(final String currentBundlePath, final String fallbackBundleId, final String nextBundleId) {
1001
+ this.currentBundlePath = currentBundlePath;
1002
+ this.fallbackBundleId = fallbackBundleId;
1003
+ this.nextBundleId = nextBundleId;
1004
+ }
1005
+ }
1006
+
1007
+ ResetState captureResetState() {
1008
+ return new ResetState(
1009
+ this.getCurrentBundlePath(),
1010
+ this.prefs.getString(FALLBACK_VERSION, BundleInfo.ID_BUILTIN),
1011
+ this.prefs.getString(NEXT_VERSION, null)
1012
+ );
1013
+ }
1014
+
1015
+ void restoreResetState(final ResetState state) {
1016
+ final String currentBundlePath = state.currentBundlePath == null || state.currentBundlePath.trim().isEmpty()
1017
+ ? "public"
1018
+ : state.currentBundlePath;
1019
+ final String fallbackBundleId = state.fallbackBundleId == null || state.fallbackBundleId.isEmpty()
1020
+ ? BundleInfo.ID_BUILTIN
1021
+ : state.fallbackBundleId;
1022
+
1023
+ this.editor.putString(this.CAP_SERVER_PATH, currentBundlePath);
1024
+ this.editor.putString(FALLBACK_VERSION, fallbackBundleId);
1025
+ if (state.nextBundleId == null || state.nextBundleId.isEmpty()) {
1026
+ this.editor.remove(NEXT_VERSION);
1027
+ } else {
1028
+ this.editor.putString(NEXT_VERSION, state.nextBundleId);
1029
+ }
1030
+ this.editor.commit();
1031
+ }
1032
+
1033
+ void prepareResetStateForTransition() {
1034
+ this.setCurrentBundle(new File("public"));
1035
+ this.setFallbackBundle(null);
1036
+ this.setNextBundle(null);
1037
+ }
1038
+
1039
+ void finalizeResetTransition(final String previousBundleName, final boolean internal) {
1040
+ if (this.activity != null) {
1041
+ DownloadWorkerManager.cancelAllDownloads(this.activity);
1042
+ }
1043
+ if (!internal) {
1044
+ this.sendStats("reset", this.getCurrentBundle().getVersionName(), previousBundleName);
1045
+ }
1046
+ }
1047
+
1048
+ boolean canSet(final BundleInfo bundle) {
1049
+ return bundle != null && (bundle.isBuiltin() || this.bundleExists(bundle.getId()));
1050
+ }
1051
+
973
1052
  public Boolean set(final BundleInfo bundle) {
974
1053
  return this.set(bundle.getId());
975
1054
  }
@@ -994,11 +1073,32 @@ public class CapgoUpdater {
994
1073
  return false;
995
1074
  }
996
1075
 
1076
+ boolean stagePendingReload(final BundleInfo bundle) {
1077
+ if (bundle == null || bundle.isBuiltin() || !this.bundleExists(bundle.getId())) {
1078
+ return false;
1079
+ }
1080
+ this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
1081
+ return true;
1082
+ }
1083
+
1084
+ void finalizePendingReload(final BundleInfo bundle, final String previousBundleName) {
1085
+ if (bundle == null || bundle.isBuiltin()) {
1086
+ return;
1087
+ }
1088
+ this.sendStats("set", bundle.getVersionName(), previousBundleName);
1089
+ }
1090
+
997
1091
  public void autoReset() {
998
1092
  final BundleInfo currentBundle = this.getCurrentBundle();
999
1093
  if (!currentBundle.isBuiltin() && !this.bundleExists(currentBundle.getId())) {
1000
1094
  logger.info("Folder at bundle path does not exist. Triggering reset.");
1001
1095
  this.reset();
1096
+ return;
1097
+ }
1098
+ String bundlePath = this.prefs.getString(this.CAP_SERVER_PATH, null);
1099
+ if (shouldResetForForeignBundle(bundlePath, currentBundle.isBuiltin(), this.hasStoredBundleInfo(currentBundle.getId()))) {
1100
+ logger.info("Current bundle id is not one of the bundle ids stored by this plugin. Triggering reset.");
1101
+ this.reset();
1002
1102
  }
1003
1103
  }
1004
1104
 
@@ -1031,17 +1131,9 @@ public class CapgoUpdater {
1031
1131
 
1032
1132
  public void reset(final boolean internal) {
1033
1133
  logger.debug("reset: " + internal);
1034
- var currentBundleName = this.getCurrentBundle().getVersionName();
1035
- this.setCurrentBundle(new File("public"));
1036
- this.setFallbackBundle(null);
1037
- this.setNextBundle(null);
1038
- // Cancel any ongoing downloads
1039
- if (this.activity != null) {
1040
- DownloadWorkerManager.cancelAllDownloads(this.activity);
1041
- }
1042
- if (!internal) {
1043
- this.sendStats("reset", this.getCurrentBundle().getVersionName(), currentBundleName);
1044
- }
1134
+ final String currentBundleName = this.getCurrentBundle().getVersionName();
1135
+ this.prepareResetStateForTransition();
1136
+ this.finalizeResetTransition(currentBundleName, internal);
1045
1137
  }
1046
1138
 
1047
1139
  private JSONObject createInfoObject() throws JSONException {
@@ -1137,6 +1229,7 @@ public class CapgoUpdater {
1137
1229
  Map<String, Object> retError = new HashMap<>();
1138
1230
  retError.put("message", "Request failed: " + e.getMessage());
1139
1231
  retError.put("error", "network_error");
1232
+ retError.put("kind", "failed");
1140
1233
  callback.callback(retError);
1141
1234
  }
1142
1235
 
@@ -1144,11 +1237,46 @@ public class CapgoUpdater {
1144
1237
  public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1145
1238
  try (ResponseBody responseBody = response.body()) {
1146
1239
  final int statusCode = response.code();
1240
+ final String responseData = responseBody != null ? responseBody.string() : "";
1241
+ JSONObject jsonResponse = null;
1242
+ if (!responseData.isEmpty()) {
1243
+ try {
1244
+ jsonResponse = new JSONObject(responseData);
1245
+ } catch (JSONException ignored) {
1246
+ // Non-JSON responses are handled as response or parse errors below.
1247
+ }
1248
+ }
1249
+
1250
+ if (jsonResponse != null && (jsonResponse.has("error") || jsonResponse.has("kind"))) {
1251
+ if (statusCode == 429) {
1252
+ checkAndHandleRateLimitResponse(response);
1253
+ }
1254
+ Map<String, Object> retError = new HashMap<>();
1255
+ if (jsonResponse.has("error") && !jsonResponse.isNull("error")) {
1256
+ retError.put("error", jsonResponse.getString("error"));
1257
+ }
1258
+ if (jsonResponse.has("kind") && !jsonResponse.isNull("kind")) {
1259
+ retError.put("kind", jsonResponse.getString("kind"));
1260
+ }
1261
+ if (jsonResponse.has("message") && !jsonResponse.isNull("message")) {
1262
+ retError.put("message", jsonResponse.getString("message"));
1263
+ } else {
1264
+ retError.put("message", "server did not provide a message");
1265
+ }
1266
+ if (jsonResponse.has("version") && !jsonResponse.isNull("version")) {
1267
+ retError.put("version", jsonResponse.getString("version"));
1268
+ }
1269
+ retError.put("statusCode", statusCode);
1270
+ callback.callback(retError);
1271
+ return;
1272
+ }
1273
+
1147
1274
  // Check for 429 rate limit
1148
1275
  if (checkAndHandleRateLimitResponse(response)) {
1149
1276
  Map<String, Object> retError = new HashMap<>();
1150
1277
  retError.put("message", "Rate limit exceeded");
1151
1278
  retError.put("error", "rate_limit_exceeded");
1279
+ retError.put("kind", "failed");
1152
1280
  retError.put("statusCode", statusCode);
1153
1281
  callback.callback(retError);
1154
1282
  return;
@@ -1158,27 +1286,14 @@ public class CapgoUpdater {
1158
1286
  Map<String, Object> retError = new HashMap<>();
1159
1287
  retError.put("message", "Server error: " + response.code());
1160
1288
  retError.put("error", "response_error");
1289
+ retError.put("kind", "failed");
1161
1290
  retError.put("statusCode", statusCode);
1162
1291
  callback.callback(retError);
1163
1292
  return;
1164
1293
  }
1165
1294
 
1166
- assert responseBody != null;
1167
- String responseData = responseBody.string();
1168
- JSONObject jsonResponse = new JSONObject(responseData);
1169
-
1170
- // Check for server-side errors first
1171
- if (jsonResponse.has("error")) {
1172
- Map<String, Object> retError = new HashMap<>();
1173
- retError.put("error", jsonResponse.getString("error"));
1174
- if (jsonResponse.has("message")) {
1175
- retError.put("message", jsonResponse.getString("message"));
1176
- } else {
1177
- retError.put("message", "server did not provide a message");
1178
- }
1179
- retError.put("statusCode", statusCode);
1180
- callback.callback(retError);
1181
- return;
1295
+ if (jsonResponse == null) {
1296
+ throw new JSONException("Response is not a JSON object");
1182
1297
  }
1183
1298
 
1184
1299
  Map<String, Object> ret = new HashMap<>();
@@ -1200,6 +1315,7 @@ public class CapgoUpdater {
1200
1315
  Map<String, Object> retError = new HashMap<>();
1201
1316
  retError.put("message", "JSON parse error: " + e.getMessage());
1202
1317
  retError.put("error", "parse_error");
1318
+ retError.put("kind", "failed");
1203
1319
  callback.callback(retError);
1204
1320
  }
1205
1321
  }
@@ -1700,7 +1816,13 @@ public class CapgoUpdater {
1700
1816
  }
1701
1817
  BundleInfo result;
1702
1818
  if (BundleInfo.ID_BUILTIN.equals(trueId)) {
1703
- result = new BundleInfo(trueId, null, BundleStatus.SUCCESS, "", "");
1819
+ result = new BundleInfo(
1820
+ trueId,
1821
+ this.versionBuild == null || this.versionBuild.isEmpty() ? null : this.versionBuild,
1822
+ BundleStatus.SUCCESS,
1823
+ "",
1824
+ ""
1825
+ );
1704
1826
  } else if (BundleInfo.VERSION_UNKNOWN.equals(trueId)) {
1705
1827
  result = new BundleInfo(trueId, null, BundleStatus.ERROR, "", "");
1706
1828
  } else {
@@ -1805,6 +1927,7 @@ public class CapgoUpdater {
1805
1927
  }
1806
1928
 
1807
1929
  public boolean setNextBundle(final String next) {
1930
+ BundleInfo bundleToNotify = null;
1808
1931
  if (next == null) {
1809
1932
  this.editor.remove(NEXT_VERSION);
1810
1933
  } else {
@@ -1814,8 +1937,15 @@ public class CapgoUpdater {
1814
1937
  }
1815
1938
  this.editor.putString(NEXT_VERSION, next);
1816
1939
  this.setBundleStatus(next, BundleStatus.PENDING);
1940
+ bundleToNotify = newBundle;
1817
1941
  }
1818
1942
  this.editor.commit();
1943
+ if (bundleToNotify != null) {
1944
+ this.sendStats("set_next", bundleToNotify.getVersionName(), this.getCurrentBundle().getVersionName());
1945
+ final Map<String, Object> payload = new HashMap<>();
1946
+ payload.put("bundle", bundleToNotify.toJSONMap());
1947
+ this.notifyListeners("setNext", payload);
1948
+ }
1819
1949
  return true;
1820
1950
  }
1821
1951
 
@@ -2,9 +2,12 @@ package ee.forgr.capacitor_updater;
2
2
 
3
3
  import android.content.SharedPreferences;
4
4
  import io.github.g00fy2.versioncompare.Version;
5
+ import java.text.ParsePosition;
5
6
  import java.text.SimpleDateFormat;
6
7
  import java.util.ArrayList;
7
8
  import java.util.Date;
9
+ import java.util.Locale;
10
+ import java.util.TimeZone;
8
11
  import org.json.JSONArray;
9
12
  import org.json.JSONException;
10
13
  import org.json.JSONObject;
@@ -98,25 +101,16 @@ public class DelayUpdateUtils {
98
101
  break;
99
102
  case date:
100
103
  if (!"".equals(value)) {
101
- try {
102
- final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
103
- Date date = sdf.parse(value);
104
- assert date != null;
104
+ Date date = parseDateCondition(value);
105
+ if (date != null) {
105
106
  if (new Date().compareTo(date) > 0) {
106
107
  logger.info("Date delay (value: " + value + ") condition removed due to expired date at index " + index);
107
108
  } else {
108
109
  delayConditionListToKeep.add(condition);
109
110
  logger.info("Date delay (value: " + value + ") condition kept at index " + index);
110
111
  }
111
- } catch (final Exception e) {
112
- logger.error(
113
- "Date delay (value: " +
114
- value +
115
- ") condition removed due to parsing issue at index " +
116
- index +
117
- " " +
118
- e.getMessage()
119
- );
112
+ } else {
113
+ logger.error("Date delay (value: " + value + ") condition removed due to parsing issue at index " + index);
120
114
  }
121
115
  } else {
122
116
  logger.debug("Date delay (value: " + value + ") condition removed due to empty value at index " + index);
@@ -190,6 +184,48 @@ public class DelayUpdateUtils {
190
184
  return conditions;
191
185
  }
192
186
 
187
+ private Date parseDateCondition(String value) {
188
+ String[] patterns = {
189
+ "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
190
+ "yyyy-MM-dd'T'HH:mm:ssXXX",
191
+ "yyyy-MM-dd'T'HH:mm:ss.SSSXX",
192
+ "yyyy-MM-dd'T'HH:mm:ssXX",
193
+ "yyyy-MM-dd'T'HH:mm:ss.SSSX",
194
+ "yyyy-MM-dd'T'HH:mm:ssX",
195
+ "yyyy-MM-dd'T'HH:mm:ss.SSS",
196
+ "yyyy-MM-dd'T'HH:mm:ss"
197
+ };
198
+
199
+ for (String pattern : patterns) {
200
+ Date parsed = parseDateWithPattern(value, pattern);
201
+ if (parsed != null) {
202
+ return parsed;
203
+ }
204
+ }
205
+
206
+ return null;
207
+ }
208
+
209
+ private Date parseDateWithPattern(String value, String pattern) {
210
+ try {
211
+ SimpleDateFormat sdf = new SimpleDateFormat(pattern, Locale.US);
212
+ sdf.setLenient(false);
213
+
214
+ // If no timezone is provided, keep historical behavior and interpret as local time.
215
+ if (!pattern.contains("X")) {
216
+ sdf.setTimeZone(TimeZone.getDefault());
217
+ }
218
+
219
+ ParsePosition position = new ParsePosition(0);
220
+ Date parsed = sdf.parse(value, position);
221
+ if (parsed != null && position.getIndex() == value.length()) {
222
+ return parsed;
223
+ }
224
+ } catch (Exception ignored) {}
225
+
226
+ return null;
227
+ }
228
+
193
229
  private String convertDelayConditionsToJson(ArrayList<DelayCondition> conditions) {
194
230
  JSONArray array = new JSONArray();
195
231
  for (DelayCondition condition : conditions) {
@@ -91,27 +91,52 @@ public class DownloadService extends Worker {
91
91
  .protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1))
92
92
  .addInterceptor((chain) -> {
93
93
  Request originalRequest = chain.request();
94
- String userAgent =
95
- "CapacitorUpdater/" +
96
- (currentPluginVersion != null ? currentPluginVersion : "unknown") +
97
- " (" +
98
- (currentAppId != null ? currentAppId : "unknown") +
99
- ") android/" +
100
- (currentVersionOs != null ? currentVersionOs : "unknown");
94
+ String userAgent = buildUserAgent(currentAppId, currentPluginVersion, currentVersionOs);
101
95
  Request requestWithUserAgent = originalRequest.newBuilder().header("User-Agent", userAgent).build();
102
96
  return chain.proceed(requestWithUserAgent);
103
97
  })
104
98
  .build();
105
99
  }
106
100
 
101
+ static String buildUserAgent(String appId, String pluginVersion, String versionOs) {
102
+ return (
103
+ "CapacitorUpdater/" +
104
+ sanitizeUserAgentValue(pluginVersion) +
105
+ " (" +
106
+ sanitizeUserAgentValue(appId) +
107
+ ") android/" +
108
+ sanitizeUserAgentValue(versionOs)
109
+ );
110
+ }
111
+
112
+ private static String sanitizeUserAgentValue(String value) {
113
+ if (value == null || value.isEmpty()) {
114
+ return "unknown";
115
+ }
116
+
117
+ StringBuilder sanitized = new StringBuilder();
118
+ value
119
+ .codePoints()
120
+ .forEach((cp) -> {
121
+ boolean isVisibleAscii = cp >= 0x20 && cp <= 0x7E;
122
+ boolean isIso88591 = cp >= 0xA0 && cp <= 0xFF;
123
+ if (isVisibleAscii || isIso88591) {
124
+ sanitized.appendCodePoint(cp);
125
+ }
126
+ });
127
+
128
+ String result = sanitized.toString().trim();
129
+ return result.isEmpty() ? "unknown" : result;
130
+ }
131
+
107
132
  // Method to update User-Agent values
108
133
  public static void updateUserAgent(String appId, String pluginVersion, String versionOs) {
109
- currentAppId = appId != null ? appId : "unknown";
110
- currentPluginVersion = pluginVersion != null ? pluginVersion : "unknown";
111
- currentVersionOs = versionOs != null ? versionOs : "unknown";
112
- logger.debug(
113
- "Updated User-Agent: CapacitorUpdater/" + currentPluginVersion + " (" + currentAppId + ") android/" + currentVersionOs
114
- );
134
+ currentAppId = sanitizeUserAgentValue(appId);
135
+ currentPluginVersion = sanitizeUserAgentValue(pluginVersion);
136
+ currentVersionOs = sanitizeUserAgentValue(versionOs);
137
+ if (logger != null) {
138
+ logger.debug("Updated User-Agent: " + buildUserAgent(currentAppId, currentPluginVersion, currentVersionOs));
139
+ }
115
140
  }
116
141
 
117
142
  public DownloadService(@NonNull Context context, @NonNull WorkerParameters params) {
@@ -249,12 +249,7 @@ public class ShakeMenu implements ShakeDetector.Listener {
249
249
  }
250
250
 
251
251
  List<?> channelsRaw = (List<?>) channelsObj;
252
- List<Map<String, Object>> channels = new ArrayList<>();
253
- for (Object item : channelsRaw) {
254
- if (item instanceof Map) {
255
- channels.add((Map<String, Object>) item);
256
- }
257
- }
252
+ List<Map<String, Object>> channels = toChannelList(channelsRaw);
258
253
 
259
254
  if (channels.isEmpty()) {
260
255
  showError("No channels available for self-assignment");
@@ -273,6 +268,27 @@ public class ShakeMenu implements ShakeDetector.Listener {
273
268
  });
274
269
  }
275
270
 
271
+ private List<Map<String, Object>> toChannelList(List<?> channelsRaw) {
272
+ List<Map<String, Object>> channels = new ArrayList<>();
273
+ for (Object item : channelsRaw) {
274
+ if (!(item instanceof Map<?, ?> rawMap)) {
275
+ continue;
276
+ }
277
+
278
+ Map<String, Object> channel = new java.util.HashMap<>();
279
+ for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
280
+ if (entry.getKey() instanceof String key) {
281
+ channel.put(key, entry.getValue());
282
+ }
283
+ }
284
+
285
+ if (!channel.isEmpty()) {
286
+ channels.add(channel);
287
+ }
288
+ }
289
+ return channels;
290
+ }
291
+
276
292
  private void presentChannelPicker(List<Map<String, Object>> channels) {
277
293
  try {
278
294
  AlertDialog.Builder builder = new AlertDialog.Builder(activity);
@@ -432,12 +448,36 @@ public class ShakeMenu implements ShakeDetector.Listener {
432
448
  }
433
449
 
434
450
  String latestError = getString(latestRes, "error");
451
+ String latestKind = getString(latestRes, "kind");
452
+ String latestMessage = getString(latestRes, "message");
453
+
454
+ String detail = latestMessage != null && !latestMessage.isEmpty()
455
+ ? latestMessage
456
+ : latestError != null && !latestError.isEmpty()
457
+ ? latestError
458
+ : latestKind != null && !latestKind.isEmpty()
459
+ ? latestKind
460
+ : "server did not provide a message";
435
461
 
436
462
  // Handle update errors first (before "no new version" check)
437
- if (latestError != null && !latestError.isEmpty() && !"no_new_version_available".equals(latestError)) {
463
+ if (
464
+ "failed".equals(latestKind) ||
465
+ (latestError != null &&
466
+ !latestError.isEmpty() &&
467
+ !"up_to_date".equals(latestKind) &&
468
+ !"blocked".equals(latestKind))
469
+ ) {
470
+ activity.runOnUiThread(() -> {
471
+ progressDialog.dismiss();
472
+ showError("Channel set to " + channelName + ". Update check failed: " + detail);
473
+ });
474
+ return;
475
+ }
476
+
477
+ if ("blocked".equals(latestKind)) {
438
478
  activity.runOnUiThread(() -> {
439
479
  progressDialog.dismiss();
440
- showError("Channel set to " + channelName + ". Update check failed: " + latestError);
480
+ showError("Channel set to " + channelName + ". Update check blocked: " + detail);
441
481
  });
442
482
  return;
443
483
  }
@@ -445,7 +485,7 @@ public class ShakeMenu implements ShakeDetector.Listener {
445
485
  String latestUrl = getString(latestRes, "url");
446
486
 
447
487
  // Check if there's an actual update available
448
- if ("no_new_version_available".equals(latestError) || latestUrl == null || latestUrl.isEmpty()) {
488
+ if ("up_to_date".equals(latestKind) || latestUrl == null || latestUrl.isEmpty()) {
449
489
  activity.runOnUiThread(() -> {
450
490
  progressDialog.dismiss();
451
491
  showSuccess("Channel set to " + channelName + ". Already on latest version.");