@capgo/capacitor-updater 8.45.11 → 8.46.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,8 @@
7
7
  package ee.forgr.capacitor_updater;
8
8
 
9
9
  import android.app.Activity;
10
+ import android.app.ActivityManager;
11
+ import android.app.ApplicationExitInfo;
10
12
  import android.content.Context;
11
13
  import android.content.Intent;
12
14
  import android.content.SharedPreferences;
@@ -20,6 +22,7 @@ import android.os.Looper;
20
22
  import android.view.Gravity;
21
23
  import android.view.View;
22
24
  import android.view.ViewGroup;
25
+ import android.webkit.RenderProcessGoneDetail;
23
26
  import android.widget.FrameLayout;
24
27
  import android.widget.ProgressBar;
25
28
  import androidx.core.content.pm.PackageInfoCompat;
@@ -32,6 +35,7 @@ import com.getcapacitor.PluginCall;
32
35
  import com.getcapacitor.PluginHandle;
33
36
  import com.getcapacitor.PluginMethod;
34
37
  import com.getcapacitor.PluginResult;
38
+ import com.getcapacitor.WebViewListener;
35
39
  import com.getcapacitor.annotation.CapacitorPlugin;
36
40
  import com.getcapacitor.plugin.WebView;
37
41
  import com.google.android.gms.tasks.Task;
@@ -51,6 +55,7 @@ import java.net.MalformedURLException;
51
55
  import java.net.URL;
52
56
  import java.util.ArrayList;
53
57
  import java.util.Date;
58
+ import java.util.HashMap;
54
59
  import java.util.HashSet;
55
60
  import java.util.List;
56
61
  import java.util.Map;
@@ -84,12 +89,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
84
89
  private static final String DEFAULT_CHANNEL_PREF_KEY = "CapacitorUpdater.defaultChannel";
85
90
  private static final String[] BREAKING_EVENT_NAMES = { "breakingAvailable", "majorAvailable" };
86
91
  private static final String LAST_FAILED_BUNDLE_PREF_KEY = "CapacitorUpdater.lastFailedBundle";
92
+ private static final String LAST_REPORTED_APP_EXIT_TIMESTAMP_PREF_KEY = "CapacitorUpdater.lastReportedAppExitTimestamp";
93
+ private static final String LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY = "CapacitorUpdater.lastWebViewRenderProcessGone";
87
94
  private static final String SPLASH_SCREEN_PLUGIN_ID = "SplashScreen";
88
95
  private static final int SPLASH_SCREEN_RETRY_DELAY_MS = 100;
89
96
  private static final int SPLASH_SCREEN_MAX_RETRIES = 20;
90
97
  private static final long PENDING_BUNDLE_APP_READY_MIN_TIMEOUT_MS = 30000L;
91
98
 
92
- private final String pluginVersion = "8.45.11";
99
+ private final String pluginVersion = "8.46.1";
93
100
  private static final String DELAY_CONDITION_PREFERENCES = "";
94
101
 
95
102
  private SharedPreferences.Editor editor;
@@ -154,6 +161,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
154
161
  private final Handler mainHandler = new Handler(Looper.getMainLooper());
155
162
  private FrameLayout splashscreenLoaderOverlay;
156
163
  private Runnable splashscreenTimeoutRunnable;
164
+ private WebViewListener webViewStatsListener;
157
165
 
158
166
  private static final class FireAndForgetPluginCall extends PluginCall {
159
167
 
@@ -210,6 +218,26 @@ public class CapacitorUpdaterPlugin extends Plugin {
210
218
  }
211
219
  }
212
220
 
221
+ private boolean shouldNotifyBreakingEvents(final JSObject response) {
222
+ if (response == null) {
223
+ return false;
224
+ }
225
+
226
+ if (response.optBoolean("breaking", false)) {
227
+ return true;
228
+ }
229
+
230
+ final String error = response.optString("error", "");
231
+ final String message = response.optString("message", "");
232
+ return "disable_auto_update_to_major".equals(error) || "store_update_required".equals(message);
233
+ }
234
+
235
+ private void notifyBreakingEventsIfNeeded(final JSObject response, final String version) {
236
+ if (shouldNotifyBreakingEvents(response)) {
237
+ notifyBreakingEvents(version);
238
+ }
239
+ }
240
+
213
241
  private void persistLastFailedBundle(BundleInfo bundle) {
214
242
  if (this.prefs == null) {
215
243
  return;
@@ -426,13 +454,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
426
454
  this.implementation.defaultChannel = this.getConfig().getString("defaultChannel", "");
427
455
  }
428
456
 
429
- int userValue = this.getConfig().getInt("periodCheckDelay", 0);
430
-
431
- if (userValue >= 0 && userValue <= 600) {
432
- this.periodCheckDelay = 600 * 1000;
433
- } else if (userValue > 600) {
434
- this.periodCheckDelay = userValue * 1000;
435
- }
457
+ this.periodCheckDelay = normalizedPeriodCheckDelayMs(this.getConfig().getInt("periodCheckDelay", 0));
436
458
 
437
459
  this.implementation.documentsDir = this.getContext().getFilesDir();
438
460
  this.implementation.prefs = this.prefs;
@@ -482,9 +504,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
482
504
  // Check if app was recently installed/updated BEFORE cleanupObsoleteVersions updates LatestVersionNative
483
505
  this.wasRecentlyInstalledOrUpdated = this.checkIfRecentlyInstalledOrUpdated();
484
506
 
485
- this.implementation.autoReset();
507
+ this.implementation.autoReset(this.currentBuildVersion, resetWhenUpdate);
508
+ this.reportPreviousAppExitReasons();
509
+ this.reportPreviousWebViewRenderProcessGone();
510
+ this.installWebViewStatsReporter();
486
511
  if (resetWhenUpdate) {
487
512
  this.cleanupObsoleteVersions();
513
+ } else {
514
+ this.persistCurrentNativeBuildVersion();
488
515
  }
489
516
 
490
517
  // Check for 'kill' delay condition on app launch
@@ -810,7 +837,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
810
837
 
811
838
  private boolean checkIfRecentlyInstalledOrUpdated() {
812
839
  String currentVersion = this.currentBuildVersion;
813
- String lastKnownVersion = this.prefs.getString("LatestNativeBuildVersion", "");
840
+ String lastKnownVersion = this.getStoredNativeBuildVersion();
814
841
 
815
842
  if (lastKnownVersion.isEmpty()) {
816
843
  // First time running, consider it as recently installed
@@ -823,6 +850,456 @@ public class CapacitorUpdaterPlugin extends Plugin {
823
850
  return false;
824
851
  }
825
852
 
853
+ private void reportPreviousAppExitReasons() {
854
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || this.implementation == null || this.implementation.statsUrl.isEmpty()) {
855
+ return;
856
+ }
857
+
858
+ try {
859
+ final ActivityManager activityManager = (ActivityManager) this.getContext().getSystemService(Context.ACTIVITY_SERVICE);
860
+ if (activityManager == null) {
861
+ return;
862
+ }
863
+
864
+ final List<ApplicationExitInfo> exitReasons = activityManager.getHistoricalProcessExitReasons(
865
+ this.getContext().getPackageName(),
866
+ 0,
867
+ 8
868
+ );
869
+ if (exitReasons == null || exitReasons.isEmpty()) {
870
+ return;
871
+ }
872
+
873
+ final long lastReportedTimestamp = this.prefs.getLong(LAST_REPORTED_APP_EXIT_TIMESTAMP_PREF_KEY, 0L);
874
+ long newestReportedTimestamp = lastReportedTimestamp;
875
+ final BundleInfo current = this.implementation.getCurrentBundle();
876
+ final String versionName = current == null ? "" : current.getVersionName();
877
+
878
+ for (final ApplicationExitInfo exitInfo : exitReasons) {
879
+ if (exitInfo == null || exitInfo.getTimestamp() <= lastReportedTimestamp) {
880
+ continue;
881
+ }
882
+
883
+ final String action = statsActionForApplicationExitReason(exitInfo.getReason());
884
+ if (action == null) {
885
+ continue;
886
+ }
887
+
888
+ this.implementation.sendStats(action, versionName, "", buildApplicationExitMetadata(exitInfo));
889
+ newestReportedTimestamp = Math.max(newestReportedTimestamp, exitInfo.getTimestamp());
890
+ }
891
+
892
+ if (newestReportedTimestamp > lastReportedTimestamp) {
893
+ this.prefs.edit().putLong(LAST_REPORTED_APP_EXIT_TIMESTAMP_PREF_KEY, newestReportedTimestamp).apply();
894
+ }
895
+ } catch (final Exception e) {
896
+ logger.warn("Unable to report previous app exit reason: " + e.getMessage());
897
+ }
898
+ }
899
+
900
+ static String statsActionForApplicationExitReason(final int reason) {
901
+ switch (reason) {
902
+ case ApplicationExitInfo.REASON_CRASH:
903
+ return "app_crash";
904
+ case ApplicationExitInfo.REASON_CRASH_NATIVE:
905
+ return "app_crash_native";
906
+ case ApplicationExitInfo.REASON_ANR:
907
+ return "app_anr";
908
+ case ApplicationExitInfo.REASON_LOW_MEMORY:
909
+ return "app_killed_low_memory";
910
+ case ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE:
911
+ return "app_killed_excessive_resource_usage";
912
+ case ApplicationExitInfo.REASON_INITIALIZATION_FAILURE:
913
+ return "app_initialization_failure";
914
+ default:
915
+ return null;
916
+ }
917
+ }
918
+
919
+ static String applicationExitReasonName(final int reason) {
920
+ switch (reason) {
921
+ case ApplicationExitInfo.REASON_EXIT_SELF:
922
+ return "exit_self";
923
+ case ApplicationExitInfo.REASON_SIGNALED:
924
+ return "signaled";
925
+ case ApplicationExitInfo.REASON_LOW_MEMORY:
926
+ return "low_memory";
927
+ case ApplicationExitInfo.REASON_CRASH:
928
+ return "crash";
929
+ case ApplicationExitInfo.REASON_CRASH_NATIVE:
930
+ return "crash_native";
931
+ case ApplicationExitInfo.REASON_ANR:
932
+ return "anr";
933
+ case ApplicationExitInfo.REASON_INITIALIZATION_FAILURE:
934
+ return "initialization_failure";
935
+ case ApplicationExitInfo.REASON_PERMISSION_CHANGE:
936
+ return "permission_change";
937
+ case ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE:
938
+ return "excessive_resource_usage";
939
+ case ApplicationExitInfo.REASON_USER_REQUESTED:
940
+ return "user_requested";
941
+ case ApplicationExitInfo.REASON_DEPENDENCY_DIED:
942
+ return "dependency_died";
943
+ default:
944
+ return "unknown";
945
+ }
946
+ }
947
+
948
+ private static Map<String, String> buildApplicationExitMetadata(final ApplicationExitInfo exitInfo) {
949
+ final Map<String, String> metadata = new HashMap<>();
950
+ metadata.put("exit_reason", applicationExitReasonName(exitInfo.getReason()));
951
+ metadata.put("exit_reason_code", Integer.toString(exitInfo.getReason()));
952
+ metadata.put("exit_status", Integer.toString(exitInfo.getStatus()));
953
+ metadata.put("exit_importance", Integer.toString(exitInfo.getImportance()));
954
+ metadata.put("exit_timestamp", Long.toString(exitInfo.getTimestamp()));
955
+ metadata.put("pid", Integer.toString(exitInfo.getPid()));
956
+ metadata.put("pss_kb", Long.toString(exitInfo.getPss()));
957
+ metadata.put("rss_kb", Long.toString(exitInfo.getRss()));
958
+
959
+ final String processName = exitInfo.getProcessName();
960
+ if (processName != null && !processName.isEmpty()) {
961
+ metadata.put("process_name", truncateStatsMetadataValue(processName, 128));
962
+ }
963
+
964
+ final String description = exitInfo.getDescription();
965
+ if (description != null && !description.isEmpty()) {
966
+ metadata.put("exit_description", truncateStatsMetadataValue(description, 512));
967
+ }
968
+
969
+ return metadata;
970
+ }
971
+
972
+ private static String truncateStatsMetadataValue(final String value, final int maxLength) {
973
+ return value.length() <= maxLength ? value : value.substring(0, maxLength);
974
+ }
975
+
976
+ private void installWebViewStatsReporter() {
977
+ if (this.bridge == null || this.bridge.getWebView() == null || this.webViewStatsListener != null) {
978
+ return;
979
+ }
980
+
981
+ final android.webkit.WebView webView = this.bridge.getWebView();
982
+ final String script = buildWebViewStatsReporterScript();
983
+ this.installDocumentStartWebViewStatsReporter(webView, script);
984
+
985
+ this.webViewStatsListener = new WebViewListener() {
986
+ @Override
987
+ public void onPageStarted(final android.webkit.WebView view) {
988
+ CapacitorUpdaterPlugin.this.evaluateWebViewStatsReporterScript(view, script);
989
+ }
990
+
991
+ @Override
992
+ public void onPageLoaded(final android.webkit.WebView view) {
993
+ CapacitorUpdaterPlugin.this.evaluateWebViewStatsReporterScript(view, script);
994
+ }
995
+
996
+ @Override
997
+ public boolean onRenderProcessGone(final android.webkit.WebView view, final RenderProcessGoneDetail detail) {
998
+ final Map<String, String> metadata = CapacitorUpdaterPlugin.this.buildWebViewRenderProcessGoneMetadata(detail);
999
+ CapacitorUpdaterPlugin.this.persistPendingWebViewRenderProcessGone(metadata);
1000
+ return false;
1001
+ }
1002
+ };
1003
+
1004
+ this.bridge.addWebViewListener(this.webViewStatsListener);
1005
+ this.evaluateWebViewStatsReporterScript(webView, script);
1006
+ }
1007
+
1008
+ private void installDocumentStartWebViewStatsReporter(final android.webkit.WebView webView, final String script) {
1009
+ try {
1010
+ final Class<?> webViewFeature = Class.forName("androidx.webkit.WebViewFeature");
1011
+ final String feature = (String) webViewFeature.getField("DOCUMENT_START_SCRIPT").get(null);
1012
+ final Boolean supported = (Boolean) webViewFeature.getMethod("isFeatureSupported", String.class).invoke(null, feature);
1013
+ if (!Boolean.TRUE.equals(supported)) {
1014
+ return;
1015
+ }
1016
+
1017
+ final String allowedOrigin = Uri.parse(this.bridge.getAppUrl())
1018
+ .buildUpon()
1019
+ .path(null)
1020
+ .fragment(null)
1021
+ .clearQuery()
1022
+ .build()
1023
+ .toString();
1024
+ final Class<?> webViewCompat = Class.forName("androidx.webkit.WebViewCompat");
1025
+ webViewCompat
1026
+ .getMethod("addDocumentStartJavaScript", android.webkit.WebView.class, String.class, Set.class)
1027
+ .invoke(null, webView, script, java.util.Collections.singleton(allowedOrigin));
1028
+ } catch (final Exception e) {
1029
+ logger.debug("Unable to install document-start WebView stats reporter: " + e.getMessage());
1030
+ }
1031
+ }
1032
+
1033
+ private void evaluateWebViewStatsReporterScript(final android.webkit.WebView webView, final String script) {
1034
+ if (webView == null) {
1035
+ return;
1036
+ }
1037
+
1038
+ this.mainHandler.post(() -> {
1039
+ try {
1040
+ webView.evaluateJavascript(script, null);
1041
+ } catch (final Exception e) {
1042
+ logger.debug("Unable to evaluate WebView stats reporter: " + e.getMessage());
1043
+ }
1044
+ });
1045
+ }
1046
+
1047
+ private Map<String, String> buildWebViewRenderProcessGoneMetadata(final RenderProcessGoneDetail detail) {
1048
+ final Map<String, String> metadata = new HashMap<>();
1049
+ metadata.put("error_type", "render_process_gone");
1050
+ metadata.put("source", "android_on_render_process_gone");
1051
+ metadata.put("timestamp", Long.toString(System.currentTimeMillis()));
1052
+ if (detail != null) {
1053
+ metadata.put("did_crash", Boolean.toString(detail.didCrash()));
1054
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1055
+ metadata.put("renderer_priority_at_exit", Integer.toString(detail.rendererPriorityAtExit()));
1056
+ }
1057
+ }
1058
+ return metadata;
1059
+ }
1060
+
1061
+ private void persistPendingWebViewRenderProcessGone(final Map<String, String> metadata) {
1062
+ try {
1063
+ final JSONObject json = new JSONObject(metadata);
1064
+ this.prefs.edit().putString(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY, json.toString()).commit();
1065
+ } catch (final Exception e) {
1066
+ logger.debug("Unable to persist WebView render process crash metadata: " + e.getMessage());
1067
+ }
1068
+ }
1069
+
1070
+ private void reportPreviousWebViewRenderProcessGone() {
1071
+ if (this.implementation == null || this.implementation.statsUrl.isEmpty()) {
1072
+ return;
1073
+ }
1074
+
1075
+ final String rawMetadata = this.prefs.getString(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY, "");
1076
+ if (rawMetadata == null || rawMetadata.isEmpty()) {
1077
+ return;
1078
+ }
1079
+
1080
+ try {
1081
+ final Map<String, String> metadata = jsonObjectToStringMap(new JSONObject(rawMetadata));
1082
+ metadata.put("reported_after_restart", "true");
1083
+ this.reportWebViewStats("webview_render_process_gone", metadata);
1084
+ this.prefs.edit().remove(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY).apply();
1085
+ } catch (final JSONException e) {
1086
+ this.prefs.edit().remove(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY).apply();
1087
+ }
1088
+ }
1089
+
1090
+ private static Map<String, String> jsonObjectToStringMap(final JSONObject json) throws JSONException {
1091
+ final Map<String, String> map = new HashMap<>();
1092
+ final JSONArray names = json.names();
1093
+ if (names == null) {
1094
+ return map;
1095
+ }
1096
+
1097
+ for (int i = 0; i < names.length(); i++) {
1098
+ final String key = names.getString(i);
1099
+ final String value = json.optString(key, "");
1100
+ if (!value.isEmpty()) {
1101
+ map.put(key, value);
1102
+ }
1103
+ }
1104
+ return map;
1105
+ }
1106
+
1107
+ @PluginMethod
1108
+ public void reportWebViewError(final PluginCall call) {
1109
+ final JSObject data = call.getData();
1110
+ this.reportWebViewStats(
1111
+ statsActionForWebViewErrorType(data.optString("type", "javascript_error")),
1112
+ buildWebViewErrorMetadata(data)
1113
+ );
1114
+ call.resolve();
1115
+ }
1116
+
1117
+ private void reportWebViewStats(final String action, final Map<String, String> metadata) {
1118
+ if (this.implementation == null) {
1119
+ return;
1120
+ }
1121
+
1122
+ final BundleInfo current = this.implementation.getCurrentBundle();
1123
+ final String versionName = current == null ? "" : current.getVersionName();
1124
+ this.implementation.sendStats(action, versionName, "", metadata);
1125
+ }
1126
+
1127
+ static String statsActionForWebViewErrorType(final String type) {
1128
+ switch (type) {
1129
+ case "unhandled_rejection":
1130
+ return "webview_unhandled_rejection";
1131
+ case "resource_error":
1132
+ return "webview_resource_error";
1133
+ case "security_policy_violation":
1134
+ return "webview_security_policy_violation";
1135
+ case "webview_unclean_restart":
1136
+ return "webview_unclean_restart";
1137
+ case "render_process_gone":
1138
+ return "webview_render_process_gone";
1139
+ case "web_content_process_terminated":
1140
+ return "webview_content_process_terminated";
1141
+ case "javascript_error":
1142
+ default:
1143
+ return "webview_javascript_error";
1144
+ }
1145
+ }
1146
+
1147
+ static Map<String, String> buildWebViewErrorMetadata(final JSObject data) {
1148
+ final Map<String, String> metadata = new HashMap<>();
1149
+ putStatsMetadataValue(metadata, "error_type", data.optString("type", "javascript_error"), 64);
1150
+ putStatsMetadataValue(metadata, "message", data.optString("message", ""), 1024);
1151
+ putStatsMetadataValue(metadata, "source", sanitizeStatsMetadataUrl(data.optString("source", "")), 512);
1152
+ putStatsMetadataValue(metadata, "line", data.optString("line", data.optString("lineno", "")), 32);
1153
+ putStatsMetadataValue(metadata, "column", data.optString("column", data.optString("colno", "")), 32);
1154
+ putStatsMetadataValue(metadata, "stack", data.optString("stack", ""), 2048);
1155
+ putStatsMetadataValue(metadata, "tag_name", data.optString("tag_name", ""), 64);
1156
+ putStatsMetadataValue(metadata, "href", sanitizeStatsMetadataUrl(data.optString("href", "")), 512);
1157
+ putStatsMetadataValue(metadata, "user_agent", data.optString("user_agent", ""), 256);
1158
+ putStatsMetadataValue(metadata, "session_id", data.optString("session_id", ""), 128);
1159
+ putStatsMetadataValue(metadata, "previous_session_id", data.optString("previous_session_id", ""), 128);
1160
+ putStatsMetadataValue(metadata, "previous_href", sanitizeStatsMetadataUrl(data.optString("previous_href", "")), 512);
1161
+ putStatsMetadataValue(metadata, "previous_started_at", data.optString("previous_started_at", ""), 64);
1162
+ putStatsMetadataValue(metadata, "previous_updated_at", data.optString("previous_updated_at", ""), 64);
1163
+ return metadata;
1164
+ }
1165
+
1166
+ private static void putStatsMetadataValue(
1167
+ final Map<String, String> metadata,
1168
+ final String key,
1169
+ final String value,
1170
+ final int maxLength
1171
+ ) {
1172
+ if (value == null || value.isEmpty()) {
1173
+ return;
1174
+ }
1175
+
1176
+ metadata.put(key, truncateStatsMetadataValue(value, maxLength));
1177
+ }
1178
+
1179
+ static String sanitizeStatsMetadataUrl(final String value) {
1180
+ if (value == null || value.isEmpty()) {
1181
+ return "";
1182
+ }
1183
+
1184
+ try {
1185
+ final java.net.URI uri = new java.net.URI(value);
1186
+ if (uri.getScheme() != null && uri.getHost() != null) {
1187
+ final String path = sanitizeStatsMetadataUrlPath(uri.getPath());
1188
+ return new java.net.URI(
1189
+ uri.getScheme(),
1190
+ null,
1191
+ uri.getHost(),
1192
+ uri.getPort(),
1193
+ path.isEmpty() ? null : path,
1194
+ null,
1195
+ null
1196
+ ).toString();
1197
+ }
1198
+ } catch (Exception ignored) {}
1199
+
1200
+ try {
1201
+ final Uri uri = Uri.parse(value);
1202
+ if (uri.getScheme() != null && uri.getHost() != null) {
1203
+ final String host = stripUrlUserInfo(uri.getHost());
1204
+ if (host.isEmpty()) {
1205
+ return stripUrlQueryAndFragment(value);
1206
+ }
1207
+ final StringBuilder authority = new StringBuilder(host);
1208
+ if (uri.getPort() != -1) {
1209
+ authority.append(':').append(uri.getPort());
1210
+ }
1211
+ final Uri.Builder builder = new Uri.Builder().scheme(uri.getScheme()).authority(authority.toString());
1212
+ final String path = sanitizeStatsMetadataUrlPath(uri.getPath());
1213
+ if (!path.isEmpty()) {
1214
+ builder.path(path);
1215
+ }
1216
+ return builder.build().toString();
1217
+ }
1218
+ } catch (Exception ignored) {}
1219
+
1220
+ return stripUrlQueryAndFragment(value);
1221
+ }
1222
+
1223
+ private static String sanitizeStatsMetadataUrlPath(final String path) {
1224
+ if (path == null || path.isEmpty()) {
1225
+ return "";
1226
+ }
1227
+
1228
+ final String[] segments = path.split("/", -1);
1229
+ for (int index = 0; index < segments.length; index++) {
1230
+ if (isSensitiveUrlPathSegment(segments[index])) {
1231
+ segments[index] = "redacted";
1232
+ }
1233
+ }
1234
+ return String.join("/", segments);
1235
+ }
1236
+
1237
+ private static String stripUrlUserInfo(final String host) {
1238
+ if (host == null || host.isEmpty()) {
1239
+ return "";
1240
+ }
1241
+
1242
+ final int userInfoIndex = host.lastIndexOf('@');
1243
+ if (userInfoIndex < 0) {
1244
+ return host;
1245
+ }
1246
+ return host.substring(userInfoIndex + 1);
1247
+ }
1248
+
1249
+ private static boolean isSensitiveUrlPathSegment(final String segment) {
1250
+ return (
1251
+ segment.matches("[0-9]{6,}") ||
1252
+ segment.matches("[0-9a-fA-F]{16,}") ||
1253
+ segment.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
1254
+ );
1255
+ }
1256
+
1257
+ private static String stripUrlQueryAndFragment(final String value) {
1258
+ int end = value.length();
1259
+ final int queryIndex = value.indexOf('?');
1260
+ final int fragmentIndex = value.indexOf('#');
1261
+ if (queryIndex >= 0) {
1262
+ end = Math.min(end, queryIndex);
1263
+ }
1264
+ if (fragmentIndex >= 0) {
1265
+ end = Math.min(end, fragmentIndex);
1266
+ }
1267
+ return value.substring(0, end);
1268
+ }
1269
+
1270
+ static String buildWebViewStatsReporterScript() {
1271
+ return (
1272
+ "(function(){" +
1273
+ "if(window.__capgoWebViewErrorReporterInstalled){return;}" +
1274
+ "window.__capgoWebViewErrorReporterInstalled=true;" +
1275
+ "var maxReports=20,sentReports=0,queue=[],seen={};" +
1276
+ "var sessionKey='CapacitorUpdater.webViewSession';" +
1277
+ "var sessionId=String(Date.now())+'-'+Math.random().toString(36).slice(2);" +
1278
+ "function s(value){try{if(value===undefined){return '';}if(value===null){return 'null';}if(typeof value==='string'){return value;}if(value&&typeof value.message==='string'){return value.message;}return String(value);}catch(_){return '';}}" +
1279
+ "function stack(value){try{return value&&value.stack?String(value.stack):'';}catch(_){return '';}}" +
1280
+ "function updater(){var cap=window.Capacitor;if(!cap||!cap.Plugins){return null;}return cap.Plugins.CapacitorUpdater||null;}" +
1281
+ "function flush(){var plugin=updater();if(!plugin||typeof plugin.reportWebViewError!=='function'){return false;}while(queue.length){var payload=queue.shift();try{var result=plugin.reportWebViewError(payload);if(result&&typeof result.catch==='function'){result.catch(function(){});}}catch(_){}}return true;}" +
1282
+ "var retries=0;function scheduleFlush(){if(flush()){return;}if(retries++<40){setTimeout(scheduleFlush,250);}}" +
1283
+ "function send(payload){try{if(sentReports>=maxReports){return;}payload.href=payload.href||location.href||'';payload.user_agent=navigator.userAgent||'';payload.session_id=sessionId;var key=[payload.type,payload.message,payload.source,payload.line,payload.column,payload.tag_name].join('|');if(seen[key]){return;}seen[key]=true;sentReports+=1;queue.push(payload);scheduleFlush();}catch(_){}}" +
1284
+ "function readSession(){try{return JSON.parse(localStorage.getItem(sessionKey)||'null')||null;}catch(_){return null;}}" +
1285
+ "function writeSession(active){try{localStorage.setItem(sessionKey,JSON.stringify({id:sessionId,active:active,href:location.href||'',started_at:window.__capgoWebViewSessionStartedAt,updated_at:String(Date.now())}));}catch(_){}}" +
1286
+ "window.__capgoWebViewSessionStartedAt=String(Date.now());" +
1287
+ "var previous=readSession();" +
1288
+ "if(previous&&previous.active){send({type:'webview_unclean_restart',message:'WebView restarted without a clean page unload',previous_session_id:s(previous.id),previous_href:s(previous.href),previous_started_at:s(previous.started_at),previous_updated_at:s(previous.updated_at)});}" +
1289
+ "writeSession(true);" +
1290
+ "setInterval(function(){writeSession(true);},15000);" +
1291
+ "function markClean(){writeSession(false);}" +
1292
+ "window.addEventListener('pagehide',markClean,true);" +
1293
+ "window.addEventListener('beforeunload',markClean,true);" +
1294
+ "window.addEventListener('error',function(event){var target=event&&event.target;if(target&&target!==window&&(target.src||target.href)){send({type:'resource_error',message:'Resource failed to load',source:s(target.src||target.href),tag_name:s(target.tagName)});return;}send({type:'javascript_error',message:s((event&&event.message)||(event&&event.error)),source:s(event&&event.filename),line:s(event&&event.lineno),column:s(event&&event.colno),stack:stack(event&&event.error)});},true);" +
1295
+ "window.addEventListener('unhandledrejection',function(event){var reason=event&&event.reason;send({type:'unhandled_rejection',message:s(reason),stack:stack(reason)});},true);" +
1296
+ "document.addEventListener('securitypolicyviolation',function(event){send({type:'security_policy_violation',message:s(event&&event.violatedDirective),source:s(event&&event.blockedURI)});},true);" +
1297
+ "document.addEventListener('deviceready',scheduleFlush,false);" +
1298
+ "setTimeout(scheduleFlush,0);" +
1299
+ "})();"
1300
+ );
1301
+ }
1302
+
826
1303
  private boolean shouldUseDirectUpdate() {
827
1304
  if (Boolean.TRUE.equals(this.autoSplashscreenTimedOut)) {
828
1305
  return false;
@@ -862,6 +1339,22 @@ public class CapacitorUpdaterPlugin extends Plugin {
862
1339
  return plannedDirectUpdate && "onLaunch".equals(directUpdateMode);
863
1340
  }
864
1341
 
1342
+ static int normalizedPeriodCheckDelayMs(final int valueSeconds) {
1343
+ final int normalizedSeconds = normalizedPeriodCheckDelaySeconds(valueSeconds);
1344
+ if (normalizedSeconds <= 0) {
1345
+ return 0;
1346
+ }
1347
+ final long delayMs = (long) normalizedSeconds * 1000L;
1348
+ return delayMs > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) delayMs;
1349
+ }
1350
+
1351
+ static int normalizedPeriodCheckDelaySeconds(final int valueSeconds) {
1352
+ if (valueSeconds <= 0) {
1353
+ return 0;
1354
+ }
1355
+ return Math.max(600, valueSeconds);
1356
+ }
1357
+
865
1358
  private void consumeOnLaunchDirectUpdateAttempt(final boolean plannedDirectUpdate) {
866
1359
  if (!shouldConsumeOnLaunchDirectUpdate(this.directUpdateMode, plannedDirectUpdate)) {
867
1360
  return;
@@ -932,7 +1425,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
932
1425
  cleanupThread = startNewThread(() -> {
933
1426
  synchronized (cleanupLock) {
934
1427
  try {
935
- final String previous = this.prefs.getString("LatestNativeBuildVersion", "");
1428
+ final String previous = this.getStoredNativeBuildVersion();
936
1429
  if (!"".equals(previous) && !Objects.equals(this.currentBuildVersion, previous)) {
937
1430
  logger.info("New native build version detected: " + this.currentBuildVersion);
938
1431
  this.implementation.reset(true);
@@ -993,6 +1486,19 @@ public class CapacitorUpdaterPlugin extends Plugin {
993
1486
  });
994
1487
  }
995
1488
 
1489
+ String getStoredNativeBuildVersion() {
1490
+ String previous = this.prefs.getString("LatestNativeBuildVersion", "");
1491
+ if (previous == null || previous.isEmpty()) {
1492
+ previous = this.prefs.getString("LatestVersionNative", "");
1493
+ }
1494
+ return previous == null ? "" : previous;
1495
+ }
1496
+
1497
+ void persistCurrentNativeBuildVersion() {
1498
+ this.editor.putString("LatestNativeBuildVersion", this.currentBuildVersion);
1499
+ this.editor.apply();
1500
+ }
1501
+
996
1502
  private void waitForCleanupIfNeeded() {
997
1503
  if (cleanupComplete) {
998
1504
  return; // Already done, no need to wait
@@ -1707,7 +2213,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
1707
2213
  String error = jsRes.has("error") ? jsRes.getString("error") : "";
1708
2214
  String errorMessage = jsRes.has("message") ? jsRes.getString("message") : "server did not provide a message";
1709
2215
  String kind = CapacitorUpdaterPlugin.this.getUpdateResponseKind(jsRes.has("kind") ? jsRes.getString("kind") : null);
2216
+ String latestVersion = jsRes.has("version") ? jsRes.getString("version") : "";
1710
2217
  jsRes.put("kind", kind);
2218
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(jsRes, latestVersion);
1711
2219
  if ("failed".equals(kind)) {
1712
2220
  logger.error("getLatest failed with error: " + error + ", message: " + errorMessage);
1713
2221
  call.reject(error.isEmpty() ? errorMessage : error);
@@ -1720,6 +2228,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
1720
2228
  }
1721
2229
  return;
1722
2230
  } else if (jsRes.has("message")) {
2231
+ String latestVersion = jsRes.has("version") ? jsRes.getString("version") : "";
2232
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(jsRes, latestVersion);
1723
2233
  call.reject(jsRes.getString("message"));
1724
2234
  return;
1725
2235
  } else {
@@ -1729,6 +2239,28 @@ public class CapacitorUpdaterPlugin extends Plugin {
1729
2239
  );
1730
2240
  }
1731
2241
 
2242
+ public String triggerBackgroundUpdateCheck() {
2243
+ if (this.updateUrl == null || this.updateUrl.isEmpty() || !this.isValidURL(this.updateUrl)) {
2244
+ logger.error("Error no url or wrong format");
2245
+ return "unavailable";
2246
+ }
2247
+ if (this.isDownloadStuckOrTimedOut()) {
2248
+ logger.info("Download already in progress, skipping duplicate download request");
2249
+ return "already_running";
2250
+ }
2251
+ this.backgroundDownload();
2252
+ return "queued";
2253
+ }
2254
+
2255
+ @PluginMethod
2256
+ public void triggerUpdateCheck(final PluginCall call) {
2257
+ final String status = this.triggerBackgroundUpdateCheck();
2258
+ final JSObject ret = new JSObject();
2259
+ ret.put("status", status);
2260
+ ret.put("queued", "queued".equals(status));
2261
+ call.resolve(ret);
2262
+ }
2263
+
1732
2264
  private boolean _reset(final Boolean toLastSuccessful, final Boolean usePendingBundle) {
1733
2265
  return this.performReset(toLastSuccessful, usePendingBundle, false);
1734
2266
  }
@@ -2065,13 +2597,17 @@ public class CapacitorUpdaterPlugin extends Plugin {
2065
2597
  }
2066
2598
  }
2067
2599
 
2068
- private String getUpdateResponseKind(final String kind) {
2600
+ static String normalizedUpdateResponseKind(final String kind) {
2069
2601
  if ("up_to_date".equals(kind) || "blocked".equals(kind) || "failed".equals(kind)) {
2070
2602
  return kind;
2071
2603
  }
2072
2604
  return "failed";
2073
2605
  }
2074
2606
 
2607
+ private String getUpdateResponseKind(final String kind) {
2608
+ return normalizedUpdateResponseKind(kind);
2609
+ }
2610
+
2075
2611
  private void notifyUpdateCheckResult(
2076
2612
  final String kind,
2077
2613
  final String error,
@@ -2206,6 +2742,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
2206
2742
  String kind = CapacitorUpdaterPlugin.this.getUpdateResponseKind(jsRes.has("kind") ? jsRes.getString("kind") : null);
2207
2743
  String latestVersion = jsRes.has("version") ? jsRes.getString("version") : current.getVersionName();
2208
2744
  CapacitorUpdaterPlugin.this.notifyUpdateCheckResult(kind, error, errorMessage, statusCode, latestVersion, current);
2745
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(
2746
+ jsRes,
2747
+ jsRes.has("version") ? jsRes.getString("version") : ""
2748
+ );
2209
2749
 
2210
2750
  if ("up_to_date".equals(kind)) {
2211
2751
  logger.info("No new version available");
@@ -2268,6 +2808,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2268
2808
  }
2269
2809
 
2270
2810
  if (!jsRes.has("url") || !CapacitorUpdaterPlugin.this.isValidURL(jsRes.getString("url"))) {
2811
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(jsRes, latestVersionName);
2271
2812
  logger.error("Error no url or wrong format");
2272
2813
  CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
2273
2814
  "Error no url or wrong format",
@@ -3174,6 +3715,11 @@ public class CapacitorUpdaterPlugin extends Plugin {
3174
3715
  logger.error("Failed to clean up AppLifecycleObserver: " + e.getMessage());
3175
3716
  }
3176
3717
  }
3718
+
3719
+ if (webViewStatsListener != null && bridge != null) {
3720
+ bridge.removeWebViewListener(webViewStatsListener);
3721
+ webViewStatsListener = null;
3722
+ }
3177
3723
  } catch (Exception e) {
3178
3724
  logger.error("Failed to run handleOnDestroy: " + e.getMessage());
3179
3725
  }