feat: 增加信令自动重连保活与控制端视频录制

- 信令客户端增加心跳保活与指数退避自动重连机制
- 服务端优化传输异常日志级别,客户端断开不再报错
- 被控端信令断开时保持服务运行并自动重连
- Flutter 控制端增加远程视频录制功能,兼容标准 WebRTC 模式切换
- 各端连接按钮增加“连接中”状态防重复点击
This commit is contained in:
TongTongStudio
2026-07-30 00:40:22 +08:00
parent 7f545359d9
commit e831ef2795
14 changed files with 444 additions and 27 deletions

View File

@@ -267,12 +267,19 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
// ---- ScreenCaptureService.ServiceStateListener 实现 ----
@Override
public void onSignalConnected() {
runOnUiThread(() -> {
// 信令服务器连接/重连成功:恢复运行状态显示。
updateUI(true);
});
}
@Override
public void onSignalDisconnected() {
runOnUiThread(() -> {
// 信令服务器断开:刷新为已停止状态,并解绑/停止服务释放资源
updateUI(false);
stopScreenSharing();
// 信令服务器短暂断开:保持服务运行WebSocketClient 会自动重连
binding.tvStatus.setText("状态: 信令断开,正在重连...");
});
}

View File

@@ -544,7 +544,12 @@ public class ScreenCaptureService extends Service {
*/
public interface ServiceStateListener {
/**
* 信令服务器断开WebSocket 断开)。
* 信令服务器已连接(首次连接或自动重连成功)。
*/
void onSignalConnected();
/**
* 信令服务器断开WebSocket 断开,客户端会自动重连)。
*/
void onSignalDisconnected();
@@ -616,6 +621,11 @@ public class ScreenCaptureService extends Service {
@Override
public void onConnected() {
Log.i(TAG, "Connected to signal server");
isShuttingDown = false; // 重连成功后重新武装
updateNotification("远程控制服务正在运行");
if (stateListener != null) {
stateListener.onSignalConnected();
}
}
@Override
@@ -837,9 +847,10 @@ public class ScreenCaptureService extends Service {
*/
private void onSignalServerDisconnected() {
if (isShuttingDown) return;
isShuttingDown = true;
Log.i(TAG, "Signal server disconnected, stopping screen sharing");
showToast(getString(R.string.signal_disconnected_message));
Log.i(TAG, "Signal server disconnected, will auto-reconnect");
// 不再直接停止服务WebSocketClient 会自动重连,服务保持运行。
showToast(getString(R.string.signal_reconnecting_message));
updateNotification("信令断开,正在重连...");
if (stateListener != null) {
stateListener.onSignalDisconnected();
}

View File

@@ -7,6 +7,11 @@ import android.util.Log;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
@@ -17,14 +22,29 @@ public class WebSocketClient {
private static final String TAG = "WebSocketClient";
// 应用层心跳:定期发送 PING避免经过反向代理nginx 等)空闲超时导致连接被断开。
private static final long HEARTBEAT_INTERVAL_MS = 25_000;
// OkHttp 协议层 ping保持 TCP 通道活跃,与心跳互补。
private static final long PING_INTERVAL_MS = 20_000;
// 自动重连指数退避参数。
private static final long RECONNECT_BASE_DELAY_MS = 2_000;
private static final long RECONNECT_MAX_DELAY_MS = 30_000;
private static final int RECONNECT_BACKOFF_STEPS = 5;
private static final String PING_PAYLOAD = "{\"type\":\"PING\"}";
private final String serverUrl;
private final String deviceId;
private final Gson gson = new GsonBuilder().disableHtmlEscaping().create();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private OkHttpClient client;
private WebSocket webSocket;
private volatile WebSocket webSocket;
private SignalListener listener;
private ScheduledExecutorService scheduler;
private int reconnectAttempts = 0;
private final AtomicBoolean manuallyClosed = new AtomicBoolean(false);
public interface SignalListener {
void onConnected();
void onDisconnected();
@@ -42,14 +62,42 @@ public class WebSocketClient {
}
public void connect() {
client = new OkHttpClient.Builder().build();
Request request = new Request.Builder().url(serverUrl).build();
manuallyClosed.set(false);
reconnectAttempts = 0;
ensureScheduler();
doConnect();
}
webSocket = client.newWebSocket(request, new WebSocketListener() {
private void ensureScheduler() {
if (scheduler == null || scheduler.isShutdown()) {
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "WebSocketClient-Scheduler");
t.setDaemon(true);
return t;
});
}
}
private void doConnect() {
if (manuallyClosed.get()) {
return;
}
Log.i(TAG, "Connecting to " + serverUrl);
client = new OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // 不依赖读超时,由心跳/ping 保活
.writeTimeout(15, TimeUnit.SECONDS)
.pingInterval(PING_INTERVAL_MS, TimeUnit.MILLISECONDS)
.build();
Request request = new Request.Builder().url(serverUrl).build();
client.newWebSocket(request, new WebSocketListener() {
@Override
public void onOpen(WebSocket ws, Response response) {
webSocket = ws;
reconnectAttempts = 0;
Log.i(TAG, "WebSocket connected");
// 发送注册消息
startHeartbeat();
registerDevice();
mainHandler.post(() -> {
if (listener != null) listener.onConnected();
@@ -72,35 +120,95 @@ public class WebSocketClient {
@Override
public void onClosed(WebSocket ws, int code, String reason) {
Log.i(TAG, "WebSocket closed: " + reason);
webSocket = null;
stopHeartbeat();
mainHandler.post(() -> {
if (listener != null) listener.onDisconnected();
});
scheduleReconnect();
}
@Override
public void onFailure(WebSocket ws, Throwable t, Response response) {
Log.e(TAG, "WebSocket error: " + t.getMessage(), t);
webSocket = null;
stopHeartbeat();
mainHandler.post(() -> {
if (listener != null) listener.onError(t.getMessage());
});
scheduleReconnect();
}
});
}
private void startHeartbeat() {
if (scheduler == null || scheduler.isShutdown()) {
return;
}
scheduler.scheduleAtFixedRate(() -> {
WebSocket ws = webSocket;
if (ws != null && !manuallyClosed.get()) {
try {
ws.send(PING_PAYLOAD);
} catch (Exception e) {
Log.w(TAG, "Heartbeat send failed", e);
}
}
}, HEARTBEAT_INTERVAL_MS, HEARTBEAT_INTERVAL_MS, TimeUnit.MILLISECONDS);
}
private void stopHeartbeat() {
// 心跳任务随 scheduler 关闭或 webSocket 置空而停止,此处无需额外处理。
}
private void scheduleReconnect() {
if (manuallyClosed.get()) {
return;
}
if (scheduler == null || scheduler.isShutdown()) {
return;
}
long delay = Math.min(
RECONNECT_BASE_DELAY_MS * (1L << Math.min(reconnectAttempts, RECONNECT_BACKOFF_STEPS)),
RECONNECT_MAX_DELAY_MS);
reconnectAttempts++;
Log.i(TAG, "Scheduling reconnect in " + delay + "ms (attempt " + reconnectAttempts + ")");
scheduler.schedule(() -> {
if (manuallyClosed.get()) {
return;
}
Log.i(TAG, "Attempting reconnect...");
doConnect();
}, delay, TimeUnit.MILLISECONDS);
}
public void sendMessage(SignalMessage message) {
if (webSocket != null) {
WebSocket ws = webSocket;
if (ws != null) {
String json = gson.toJson(message);
Log.d(TAG, "Sending: " + json);
webSocket.send(json);
ws.send(json);
}
}
public void disconnect() {
if (webSocket != null) {
webSocket.close(1000, "Disconnecting");
manuallyClosed.set(true);
WebSocket ws = webSocket;
webSocket = null;
stopHeartbeat();
if (scheduler != null) {
scheduler.shutdownNow();
scheduler = null;
}
if (ws != null) {
try {
ws.close(1000, "Disconnecting");
} catch (Exception ignored) {
}
}
if (client != null) {
client.dispatcher().executorService().shutdown();
client = null;
}
}

View File

@@ -6,6 +6,7 @@
<string name="connection_accept">接受</string>
<string name="connection_reject">拒绝</string>
<string name="signal_disconnected_message">与信令服务器断开,屏幕共享已停止</string>
<string name="signal_reconnecting_message">与信令服务器断开,正在尝试重连…</string>
<string name="controller_disconnected_message">控制端(%s已断开远程控制</string>
<string name="accessibility_service_description">用于远程控制时模拟键盘与触摸输入。开启后,控制端可通过本服务向本机发送返回、主页、音量等系统按键及触摸手势。</string>
<string name="accessibility_hint">未使用系统签名:如需在本机模拟键盘/触摸,请在系统设置中开启本应用的无障碍服务。</string>