|
|
@@ -17,6 +17,7 @@ import android.view.KeyEvent;
|
|
|
import android.view.View;
|
|
|
import android.webkit.JavascriptInterface;
|
|
|
import android.webkit.SslErrorHandler;
|
|
|
+import android.webkit.ValueCallback;
|
|
|
import android.webkit.WebResourceError;
|
|
|
import android.webkit.WebResourceRequest;
|
|
|
import android.webkit.WebSettings;
|
|
|
@@ -38,6 +39,8 @@ import com.google.android.exoplayer2.audio.AudioAttributes;
|
|
|
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory;
|
|
|
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
|
|
|
import android.content.Intent;
|
|
|
+import org.json.JSONArray;
|
|
|
+import org.json.JSONObject;
|
|
|
import pl.droidsonroids.gif.GifImageView;
|
|
|
|
|
|
public class MainActivity extends BaseActivity {
|
|
|
@@ -122,6 +125,20 @@ public class MainActivity extends BaseActivity {
|
|
|
private Handler handlerWebErr;
|
|
|
private int pageRetryCount = 0;
|
|
|
private static final int MAX_PAGE_RETRY = 5;
|
|
|
+ private Handler webHealthHandler;
|
|
|
+ private Runnable webHealthRunnable;
|
|
|
+ private int webHealthFailCount = 0;
|
|
|
+ private int webHealthQuerySeq = 0;
|
|
|
+ private long socketUnhealthySinceAt = 0;
|
|
|
+ private long lastSocketWatchdogAt = 0;
|
|
|
+ private long lastWebReloadAt = 0;
|
|
|
+ private static final long WEB_HEALTH_INTERVAL_MS = 30 * 1000L;
|
|
|
+ private static final long WEB_HEALTH_QUERY_TIMEOUT_MS = 8 * 1000L;
|
|
|
+ private static final int WEB_HEALTH_FAIL_RELOAD_COUNT = 2;
|
|
|
+ private static final long WEB_RELOAD_COOLDOWN_MS = 3 * 60 * 1000L;
|
|
|
+ private static final long SOCKET_WATCHDOG_COOLDOWN_MS = 60 * 1000L;
|
|
|
+ private static final long SOCKET_UNHEALTHY_MS = 45 * 1000L;
|
|
|
+ private static final long PAGE_TICK_STALE_MS = 90 * 1000L;
|
|
|
|
|
|
// ❻ 生命周期观察者(管理播放器暂停/恢复)
|
|
|
private final LifecycleEventObserver lifecycleObserver = (source, event) -> {
|
|
|
@@ -142,12 +159,14 @@ public class MainActivity extends BaseActivity {
|
|
|
player.play();
|
|
|
}
|
|
|
notifyJsReady();
|
|
|
+ runWebHealthCheck();
|
|
|
break;
|
|
|
case ON_DESTROY:
|
|
|
// 销毁时清理Handler
|
|
|
if (handlerWebErr != null) {
|
|
|
handlerWebErr.removeCallbacksAndMessages(null);
|
|
|
}
|
|
|
+ stopWebHealthWatchdog();
|
|
|
break;
|
|
|
}
|
|
|
};
|
|
|
@@ -193,6 +212,7 @@ public class MainActivity extends BaseActivity {
|
|
|
|
|
|
//初始化
|
|
|
init();
|
|
|
+ startWebHealthWatchdog();
|
|
|
|
|
|
//版本判断
|
|
|
// appVersionCheck();
|
|
|
@@ -349,6 +369,7 @@ public class MainActivity extends BaseActivity {
|
|
|
webIDs.setVisibility(View.VISIBLE);
|
|
|
// reload 后通知前端 native 就绪,前端据此复位忙标志并 drain 队列
|
|
|
notifyJsReady();
|
|
|
+ runWebHealthCheck();
|
|
|
}
|
|
|
|
|
|
@Override
|
|
|
@@ -383,6 +404,7 @@ public class MainActivity extends BaseActivity {
|
|
|
}
|
|
|
}, delayMs);
|
|
|
}
|
|
|
+
|
|
|
};
|
|
|
|
|
|
|
|
|
@@ -423,6 +445,176 @@ public class MainActivity extends BaseActivity {
|
|
|
return value.replace("\\", "\\\\").replace("'", "\\'");
|
|
|
}
|
|
|
|
|
|
+ private void startWebHealthWatchdog() {
|
|
|
+ if (webHealthHandler == null) {
|
|
|
+ webHealthHandler = new Handler(Looper.getMainLooper());
|
|
|
+ }
|
|
|
+ if (webHealthRunnable != null) {
|
|
|
+ webHealthHandler.removeCallbacks(webHealthRunnable);
|
|
|
+ }
|
|
|
+ webHealthRunnable = new Runnable() {
|
|
|
+ @Override
|
|
|
+ public void run() {
|
|
|
+ runWebHealthCheck();
|
|
|
+ if (webHealthHandler != null) {
|
|
|
+ webHealthHandler.postDelayed(this, WEB_HEALTH_INTERVAL_MS);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ webHealthHandler.postDelayed(webHealthRunnable, WEB_HEALTH_INTERVAL_MS);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void stopWebHealthWatchdog() {
|
|
|
+ if (webHealthHandler != null) {
|
|
|
+ webHealthHandler.removeCallbacksAndMessages(null);
|
|
|
+ }
|
|
|
+ webHealthRunnable = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String normalizeEvaluateResult(String value) {
|
|
|
+ if (value == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ String text = value.trim();
|
|
|
+ if (text.isEmpty() || "null".equals(text) || "undefined".equals(text)) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ if (text.length() >= 2 && text.startsWith("\"") && text.endsWith("\"")) {
|
|
|
+ try {
|
|
|
+ return new JSONArray("[" + text + "]").optString(0, "");
|
|
|
+ } catch (Exception e) {
|
|
|
+ Log.e("webhealth", "解析 JS 字符串结果失败: " + e.getMessage());
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return text;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void runWebHealthCheck() {
|
|
|
+ if (isFinishing() || isDestroyed() || webIDs == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (Looper.getMainLooper() != Looper.myLooper()) {
|
|
|
+ runOnUiThread(this::runWebHealthCheck);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ final int querySeq = ++webHealthQuerySeq;
|
|
|
+ if (webHealthHandler == null) {
|
|
|
+ webHealthHandler = new Handler(Looper.getMainLooper());
|
|
|
+ }
|
|
|
+ webHealthHandler.postDelayed(() -> {
|
|
|
+ if (querySeq == webHealthQuerySeq) {
|
|
|
+ handleWebHealthFailure("health query timeout");
|
|
|
+ }
|
|
|
+ }, WEB_HEALTH_QUERY_TIMEOUT_MS);
|
|
|
+ try {
|
|
|
+ webIDs.evaluateJavascript(
|
|
|
+ "(function(){try{return window.__CALLAPP_HEALTH__?window.__CALLAPP_HEALTH__():null;}catch(e){return {error:String(e&&e.message||e)};}})()",
|
|
|
+ new ValueCallback<String>() {
|
|
|
+ @Override
|
|
|
+ public void onReceiveValue(String value) {
|
|
|
+ if (querySeq != webHealthQuerySeq) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ webHealthQuerySeq++;
|
|
|
+ handleWebHealthResult(value);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ );
|
|
|
+ } catch (Exception e) {
|
|
|
+ webHealthQuerySeq++;
|
|
|
+ handleWebHealthFailure("health query exception: " + e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void handleWebHealthResult(String value) {
|
|
|
+ try {
|
|
|
+ String text = normalizeEvaluateResult(value);
|
|
|
+ if (text.isEmpty()) {
|
|
|
+ handleWebHealthFailure("health empty");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ JSONObject health = new JSONObject(text);
|
|
|
+ if (health.has("error")) {
|
|
|
+ handleWebHealthFailure("health js error: " + health.optString("error"));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ long now = System.currentTimeMillis();
|
|
|
+ long pageTickAt = health.optLong("pageTickAt", health.optLong("aliveAt", now));
|
|
|
+ boolean visibleLogin = health.optBoolean("visibleLogin", false);
|
|
|
+ if (!visibleLogin && pageTickAt > 0 && now - pageTickAt > PAGE_TICK_STALE_MS) {
|
|
|
+ handleWebHealthFailure("page tick stale");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ webHealthFailCount = 0;
|
|
|
+
|
|
|
+ boolean socketConnected = health.optBoolean("socketConnected", false);
|
|
|
+ long lastConnectAt = health.optLong("lastConnectAtMs", 0);
|
|
|
+ long lastPongAt = health.optLong("lastPongAtMs", 0);
|
|
|
+ long lastHeartbeatAt = health.optLong("lastHeartbeatAtMs", 0);
|
|
|
+ long serverAliveAt = Math.max(Math.max(lastPongAt, lastHeartbeatAt), lastConnectAt);
|
|
|
+ boolean serverHeartbeatStale = socketConnected && serverAliveAt > 0 && now - serverAliveAt > SOCKET_UNHEALTHY_MS;
|
|
|
+ if (!visibleLogin && (!socketConnected || serverHeartbeatStale)) {
|
|
|
+ if (socketUnhealthySinceAt == 0) {
|
|
|
+ socketUnhealthySinceAt = now;
|
|
|
+ }
|
|
|
+ if (now - socketUnhealthySinceAt >= SOCKET_UNHEALTHY_MS) {
|
|
|
+ requestFrontendSocketRecreate(serverHeartbeatStale ? "android watchdog heartbeat stale" : "android watchdog socket disconnected");
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ socketUnhealthySinceAt = 0;
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ handleWebHealthFailure("health parse failed: " + e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void handleWebHealthFailure(String reason) {
|
|
|
+ webHealthFailCount++;
|
|
|
+ Log.w("webhealth", "健康检查失败(" + webHealthFailCount + "): " + reason);
|
|
|
+ if (webHealthFailCount >= WEB_HEALTH_FAIL_RELOAD_COUNT) {
|
|
|
+ reloadWebViewSafely(reason);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void requestFrontendSocketRecreate(String reason) {
|
|
|
+ long now = System.currentTimeMillis();
|
|
|
+ if (now - lastSocketWatchdogAt < SOCKET_WATCHDOG_COOLDOWN_MS) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ lastSocketWatchdogAt = now;
|
|
|
+ if (webIDs == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ String script = "window.__CALLAPP_FORCE_RECREATE_SOCKET__ && window.__CALLAPP_FORCE_RECREATE_SOCKET__('" + escapeJsArg(reason) + "')";
|
|
|
+ try {
|
|
|
+ webIDs.evaluateJavascript(script, null);
|
|
|
+ Log.w("webhealth", "已请求前端重建 Socket: " + reason);
|
|
|
+ } catch (Exception e) {
|
|
|
+ Log.e("webhealth", "请求前端重建 Socket 失败: " + e.getMessage());
|
|
|
+ handleWebHealthFailure("socket recreate call failed");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void reloadWebViewSafely(String reason) {
|
|
|
+ long now = System.currentTimeMillis();
|
|
|
+ if (now - lastWebReloadAt < WEB_RELOAD_COOLDOWN_MS) {
|
|
|
+ Log.w("webhealth", "跳过 WebView reload,仍在冷却期: " + reason);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ lastWebReloadAt = now;
|
|
|
+ webHealthFailCount = 0;
|
|
|
+ socketUnhealthySinceAt = 0;
|
|
|
+ try {
|
|
|
+ if (webIDs != null) {
|
|
|
+ Log.e("webhealth", "WebView watchdog reload: " + reason);
|
|
|
+ webIDs.reload();
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ Log.e("webhealth", "WebView reload 失败: " + e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private void notifyJs(String method, String param, String playId) {
|
|
|
// 检查Activity状态,避免销毁后调用
|
|
|
if (isFinishing() || isDestroyed() || webIDs == null) {
|
|
|
@@ -620,6 +812,7 @@ public class MainActivity extends BaseActivity {
|
|
|
audioHandler = null;
|
|
|
audioTimeoutRunnable = null;
|
|
|
}
|
|
|
+ stopWebHealthWatchdog();
|
|
|
|
|
|
// 4. 释放WebView(避免泄漏)
|
|
|
if (webIDs != null) {
|