feat(controlled): 实现设备激活与安全认证流程
- 添加API客户端、加密存储和provision/token激活逻辑 - WebSocket改用Bearer令牌认证,移除REGISTER请求 - 设备ID改为服务端下发,支持令牌刷新和强制下线处理 - 新增deviceSecret加密存储和accessToken自动刷新 - 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
@@ -21,6 +21,9 @@ android {
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
// 服务端地址(信令 wss 与 HTTP api 同源)。部署时通过 flavor / CI 注入真实值。
|
||||
buildConfigField "String", "API_BASE", "\"https://www.ttstd.com\""
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -145,4 +148,6 @@ dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
// Protobuf(DataChannel 控制指令二进制)
|
||||
implementation 'com.google.protobuf:protobuf-java:3.25.1'
|
||||
// 安全存储:加密 SharedPreferences(保存 accessToken / refreshToken)
|
||||
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
||||
}
|
||||
|
||||
@@ -34,23 +34,31 @@ import java.util.List;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.ttstd.control.Action;
|
||||
import com.ttstd.control.ControlMessage;
|
||||
import com.ttstd.controller.signaling.ApiClient;
|
||||
import com.ttstd.controller.signaling.SignalMessage;
|
||||
import com.ttstd.controller.signaling.WebSocketClient;
|
||||
import com.ttstd.controller.view.RemoteTouchView;
|
||||
import com.ttstd.controller.utils.DeviceUtils;
|
||||
import com.ttstd.controller.webrtc.SelfCodecDecoder;
|
||||
import com.ttstd.controller.webrtc.VideoRecorder;
|
||||
import com.ttstd.controller.webrtc.WebRtcClient;
|
||||
import com.ttstd.controller.utils.TokenStore;
|
||||
|
||||
import org.webrtc.EglBase;
|
||||
import org.webrtc.IceCandidate;
|
||||
import org.webrtc.RendererCommon;
|
||||
import org.webrtc.SurfaceViewRenderer;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
@@ -98,11 +106,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
private WebRtcClient webRtcClient;
|
||||
private EglBase eglBase;
|
||||
private final Gson gson = new Gson();
|
||||
private ApiClient apiClient;
|
||||
private TokenStore tokenStore;
|
||||
private String myDeviceId;
|
||||
private String targetDeviceId;
|
||||
private String pendingAuthType;
|
||||
private String pendingAuthValue;
|
||||
|
||||
// 心跳定时器(每 25s 发送 PING,配合 OkHttp pingInterval 保活)。
|
||||
private ScheduledExecutorService heartbeatScheduler;
|
||||
private final AtomicBoolean refreshInFlight = new AtomicBoolean(false);
|
||||
|
||||
private final Handler statsHandler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable statsRunnable = new Runnable() {
|
||||
@Override
|
||||
@@ -143,9 +157,13 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
// 默认服务器地址
|
||||
etServerUrl.setText("wss://www.ttstd.com/signal");
|
||||
// 生成设备ID(非系统签名,使用兜底方案获取设备标识)
|
||||
myDeviceId = DeviceUtils.getSerialNumber(this);
|
||||
etDeviceId.setText(myDeviceId);
|
||||
// 设备ID 由服务端注册后下发(REGISTER_SUCCESS.fromDeviceId),用户无需填写。
|
||||
etDeviceId.setEnabled(false);
|
||||
etDeviceId.setHint("连接后由服务端下发");
|
||||
|
||||
// 初始化账号相关客户端与令牌存储
|
||||
apiClient = new ApiClient();
|
||||
tokenStore = new TokenStore(this);
|
||||
|
||||
btnConnect.setOnClickListener(v -> showAuthDialog());
|
||||
btnDisconnect.setOnClickListener(v -> disconnect());
|
||||
@@ -312,9 +330,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
private void showAuthDialog() {
|
||||
String serverUrl = etServerUrl.getText().toString().trim();
|
||||
String target = etTargetDeviceId.getText().toString().trim();
|
||||
String myId = etDeviceId.getText().toString().trim();
|
||||
if (serverUrl.isEmpty() || target.isEmpty() || myId.isEmpty()) {
|
||||
Toast.makeText(this, "请先填写服务器地址、设备ID和目标设备ID", Toast.LENGTH_SHORT).show();
|
||||
if (serverUrl.isEmpty() || target.isEmpty()) {
|
||||
Toast.makeText(this, "请先填写服务器地址和目标设备ID", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -369,36 +386,141 @@ public class MainActivity extends AppCompatActivity {
|
||||
pendingAuthValue = authValue;
|
||||
}
|
||||
dialog.dismiss();
|
||||
connectToControlled();
|
||||
// 先确保已登录(Bearer token),再发起信令连接。
|
||||
ensureAuthenticated(this::connectToControlled);
|
||||
}));
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保已登录:若本地已有 accessToken 则直接回调;否则弹出登录对话框,
|
||||
* 登录成功后回调。登录失败则提示并中止。
|
||||
*/
|
||||
private void ensureAuthenticated(Runnable onAuthenticated) {
|
||||
if (tokenStore.hasTokens()) {
|
||||
onAuthenticated.run();
|
||||
return;
|
||||
}
|
||||
showLoginDialog(success -> {
|
||||
if (success) onAuthenticated.run();
|
||||
else Toast.makeText(this, "请先登录账号", Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
}
|
||||
|
||||
/** 登录对话框:输入用户名/密码,调用 /api/auth/login,保存令牌。 */
|
||||
private void showLoginDialog(LoginCallback callback) {
|
||||
View view = getLayoutInflater().inflate(R.layout.dialog_login, null);
|
||||
EditText etUser = view.findViewById(R.id.et_username);
|
||||
EditText etPass = view.findViewById(R.id.et_password);
|
||||
AlertDialog dialog = new AlertDialog.Builder(this)
|
||||
.setTitle("登录")
|
||||
.setView(view)
|
||||
.setNegativeButton("取消", (d, w) -> callback.onResult(false))
|
||||
.setPositiveButton("登录", null)
|
||||
.create();
|
||||
dialog.setOnShowListener(d -> dialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
.setOnClickListener(v -> {
|
||||
String user = etUser.getText().toString().trim();
|
||||
String pass = etPass.getText().toString().trim();
|
||||
if (user.isEmpty() || pass.isEmpty()) {
|
||||
Toast.makeText(this, "请输入用户名和密码", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
new Thread(() -> {
|
||||
try {
|
||||
JsonObject resp = apiClient.login(user, pass);
|
||||
String at = resp.has("accessToken") ? resp.get("accessToken").getAsString() : null;
|
||||
String rt = resp.has("refreshToken") ? resp.get("refreshToken").getAsString() : null;
|
||||
if (at == null || rt == null) throw new IllegalStateException("登录返回缺失");
|
||||
tokenStore.save(at, rt, user);
|
||||
runOnUiThread(() -> {
|
||||
dialog.dismiss();
|
||||
callback.onResult(true);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
runOnUiThread(() -> Toast.makeText(this, "登录失败: " + e.getMessage(), Toast.LENGTH_LONG).show());
|
||||
}
|
||||
}).start();
|
||||
}));
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
/** 登录结果回调。 */
|
||||
private interface LoginCallback {
|
||||
void onResult(boolean success);
|
||||
}
|
||||
|
||||
private void connectToControlled() {
|
||||
String serverUrl = etServerUrl.getText().toString().trim();
|
||||
targetDeviceId = etTargetDeviceId.getText().toString().trim();
|
||||
myDeviceId = etDeviceId.getText().toString().trim();
|
||||
|
||||
if (serverUrl.isEmpty() || targetDeviceId.isEmpty() || myDeviceId.isEmpty()) {
|
||||
Toast.makeText(this, "请填写所有字段", Toast.LENGTH_SHORT).show();
|
||||
if (serverUrl.isEmpty() || targetDeviceId.isEmpty()) {
|
||||
Toast.makeText(this, "请填写服务器地址和目标设备ID", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
tvStatus.setText("状态: 正在连接信令服务器...");
|
||||
|
||||
// 初始化 WebSocket
|
||||
wsClient = new WebSocketClient(serverUrl, myDeviceId);
|
||||
// 确保 accessToken 有效(必要时刷新),随后携带 Bearer token 建立 WebSocket。
|
||||
new Thread(() -> {
|
||||
try {
|
||||
String token = ensureAccessToken();
|
||||
runOnUiThread(() -> buildWebSocketAndConnect(serverUrl, token));
|
||||
} catch (Exception e) {
|
||||
runOnUiThread(() -> {
|
||||
tvStatus.setText("状态: 认证失败 - " + e.getMessage());
|
||||
Toast.makeText(this, "登录已失效,请重新登录", Toast.LENGTH_LONG).show();
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
/** 返回有效的 accessToken:若本地有则校验,失效则用 refreshToken 刷新。 */
|
||||
private String ensureAccessToken() {
|
||||
String at = tokenStore.getAccessToken();
|
||||
if (at != null) {
|
||||
try {
|
||||
apiClient.verify(at);
|
||||
return at;
|
||||
} catch (ApiClient.ApiException e) {
|
||||
if (e.httpCode != 401) return at; // 非鉴权错误,暂用原 token
|
||||
}
|
||||
}
|
||||
// 刷新
|
||||
String rt = tokenStore.getRefreshToken();
|
||||
if (rt == null) throw new IllegalStateException("无 refreshToken");
|
||||
JsonObject resp = apiClient.refresh(rt);
|
||||
String newAt = resp.has("accessToken") ? resp.get("accessToken").getAsString() : null;
|
||||
if (newAt == null) throw new IllegalStateException("刷新失败");
|
||||
tokenStore.saveAccessToken(newAt);
|
||||
if (resp.has("refreshToken")) tokenStore.saveRefreshToken(resp.get("refreshToken").getAsString());
|
||||
return newAt;
|
||||
}
|
||||
|
||||
private void buildWebSocketAndConnect(String serverUrl, String token) {
|
||||
wsClient = new WebSocketClient(serverUrl, token);
|
||||
wsClient.setListener(new WebSocketClient.SignalListener() {
|
||||
@Override
|
||||
public void onConnected() {
|
||||
tvStatus.setText("状态: 已连接信令服务器,正在发起连接...");
|
||||
// 连接成功后初始化 WebRTC 并创建 Offer
|
||||
public void onRegistered(String fromDeviceId) {
|
||||
// 服务端下发本机 deviceId(CONTROLLER),用于 WebRTC Offer 标识。
|
||||
myDeviceId = fromDeviceId;
|
||||
etDeviceId.setText(fromDeviceId);
|
||||
tvStatus.setText("状态: 已注册 (" + fromDeviceId + "),正在发起连接...");
|
||||
// 注册成功后初始化 WebRTC 并创建 Offer(myDeviceId 此时已就绪)。
|
||||
initWebRtcAndConnect();
|
||||
// 拉取可连接的被控端绑定列表(仅已绑定设备)
|
||||
loadBindings();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnected() {
|
||||
tvStatus.setText("状态: 已连接信令服务器,等待注册...");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected() {
|
||||
tvStatus.setText("状态: 已断开连接");
|
||||
stopHeartbeat();
|
||||
updateUI(false);
|
||||
}
|
||||
|
||||
@@ -413,8 +535,99 @@ public class MainActivity extends AppCompatActivity {
|
||||
public void onMessage(SignalMessage message) {
|
||||
handleSignalMessage(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTokenExpired() {
|
||||
// 关闭码 4001:单飞刷新令牌后重连。
|
||||
if (refreshInFlight.compareAndSet(false, true)) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
String rt = tokenStore.getRefreshToken();
|
||||
if (rt == null) throw new IllegalStateException("无 refreshToken");
|
||||
JsonObject resp = apiClient.refresh(rt);
|
||||
String newAt = resp.has("accessToken") ? resp.get("accessToken").getAsString() : null;
|
||||
if (newAt == null) throw new IllegalStateException("刷新失败");
|
||||
tokenStore.saveAccessToken(newAt);
|
||||
if (resp.has("refreshToken")) tokenStore.saveRefreshToken(resp.get("refreshToken").getAsString());
|
||||
String srv = etServerUrl.getText().toString().trim();
|
||||
runOnUiThread(() -> buildWebSocketAndConnect(srv, newAt));
|
||||
} catch (Exception e) {
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(MainActivity.this, "令牌刷新失败,请重新登录", Toast.LENGTH_LONG).show();
|
||||
logoutAndReset();
|
||||
});
|
||||
} finally {
|
||||
refreshInFlight.set(false);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onForceLogout() {
|
||||
// 关闭码 4003:强制下线,停止重连并跳回登录。
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(MainActivity.this, "账号已在其他位置登录,已强制下线", Toast.LENGTH_LONG).show();
|
||||
logoutAndReset();
|
||||
});
|
||||
}
|
||||
});
|
||||
wsClient.connect();
|
||||
startHeartbeat();
|
||||
}
|
||||
|
||||
/** 退出登录并重置 UI 状态(清空令牌、停止连接)。 */
|
||||
private void logoutAndReset() {
|
||||
if (wsClient != null) wsClient.disconnect();
|
||||
wsClient = null;
|
||||
if (webRtcClient != null) { webRtcClient.close(); webRtcClient = null; }
|
||||
stopHeartbeat();
|
||||
tokenStore.clear();
|
||||
myDeviceId = null;
|
||||
etDeviceId.setText("");
|
||||
tvStatus.setText("状态: 已退出登录");
|
||||
Toast.makeText(this, "请重新登录后再连接", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
private void startHeartbeat() {
|
||||
stopHeartbeat();
|
||||
heartbeatScheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
heartbeatScheduler.scheduleAtFixedRate(() -> {
|
||||
if (wsClient != null) wsClient.sendHeartbeat();
|
||||
}, 25, 25, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void stopHeartbeat() {
|
||||
if (heartbeatScheduler != null) {
|
||||
heartbeatScheduler.shutdownNow();
|
||||
heartbeatScheduler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉取本机可连接的被控端(仅已绑定设备),用于辅助用户选择目标。 */
|
||||
private void loadBindings() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
JsonObject resp = apiClient.bindings(tokenStore.getAccessToken());
|
||||
if (resp == null || !resp.has("bindings")) return;
|
||||
List<String> list = new ArrayList<>();
|
||||
for (JsonElement el : resp.getAsJsonArray("bindings")) {
|
||||
if (el.isJsonObject()) {
|
||||
JsonObject o = el.getAsJsonObject();
|
||||
String uid = o.has("deviceUid") ? o.get("deviceUid").getAsString()
|
||||
: (o.has("deviceId") ? o.get("deviceId").getAsString() : "");
|
||||
list.add(uid);
|
||||
} else {
|
||||
list.add(el.getAsString());
|
||||
}
|
||||
}
|
||||
if (!list.isEmpty()) {
|
||||
runOnUiThread(() -> Toast.makeText(this,
|
||||
"已绑定设备: " + String.join(", ", list), Toast.LENGTH_LONG).show());
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void initWebRtcAndConnect() {
|
||||
@@ -637,6 +850,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void disconnect() {
|
||||
stopHeartbeat();
|
||||
if (webRtcClient != null) {
|
||||
webRtcClient.close();
|
||||
webRtcClient = null;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.ttstd.controller.signaling;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.ttstd.controller.BuildConfig;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
/**
|
||||
* 主控端 HTTP 客户端:对接安全信令服务器的账号体系与自助接口。
|
||||
*
|
||||
* - login(username,password) → accessToken + refreshToken(一次性,ses_ 前缀);
|
||||
* - refresh(refreshToken) → 新 accessToken(服务端可能轮换 refreshToken);
|
||||
* - verify() → 校验 accessToken 是否仍有效;
|
||||
* - bindings() → 本机可连接的被控端列表(仅已绑定设备);
|
||||
* - turnCredentials() → TURN 短期凭证(服务端开启时返回 iceServers)。
|
||||
*
|
||||
* 刷新单飞锁由调用方(MainActivity)保证并发只触发一次。
|
||||
*/
|
||||
public final class ApiClient {
|
||||
|
||||
private static final String TAG = "ControllerApiClient";
|
||||
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
||||
|
||||
private final OkHttpClient http;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
public ApiClient() {
|
||||
this.http = new OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static String apiBase() {
|
||||
return BuildConfig.API_BASE;
|
||||
}
|
||||
|
||||
public JsonObject login(String username, String password) {
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("username", username);
|
||||
body.addProperty("password", password);
|
||||
return post("/api/auth/login", body, false, null);
|
||||
}
|
||||
|
||||
public JsonObject register(String username, String password) {
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("username", username);
|
||||
body.addProperty("password", password);
|
||||
return post("/api/auth/register", body, false, null);
|
||||
}
|
||||
|
||||
/** 刷新令牌。需带 refreshToken(作为 body)。 */
|
||||
public JsonObject refresh(String refreshToken) {
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("refreshToken", refreshToken);
|
||||
return post("/api/auth/refresh", body, false, null);
|
||||
}
|
||||
|
||||
public JsonObject verify(String accessToken) {
|
||||
return get("/api/client/verify", accessToken);
|
||||
}
|
||||
|
||||
public JsonObject bindings(String accessToken) {
|
||||
return get("/api/client/bindings", accessToken);
|
||||
}
|
||||
|
||||
public JsonObject turnCredentials(String accessToken) {
|
||||
return get("/api/client/turn-credentials", accessToken);
|
||||
}
|
||||
|
||||
private JsonObject post(String path, JsonObject body, boolean auth, String token) {
|
||||
Request.Builder b = new Request.Builder().url(apiBase() + path)
|
||||
.post(RequestBody.create(body.toString(), JSON));
|
||||
if (auth && token != null) b.addHeader("Authorization", "Bearer " + token);
|
||||
return call(b.build(), path);
|
||||
}
|
||||
|
||||
private JsonObject get(String path, String token) {
|
||||
Request.Builder b = new Request.Builder().url(apiBase() + path).get();
|
||||
if (token != null) b.addHeader("Authorization", "Bearer " + token);
|
||||
return call(b.build(), path);
|
||||
}
|
||||
|
||||
private JsonObject call(Request request, String step) {
|
||||
try (Response resp = http.newCall(request).execute()) {
|
||||
ResponseBody body = resp.body();
|
||||
String text = body != null ? body.string() : "";
|
||||
if (!resp.isSuccessful()) {
|
||||
throw new ApiException("HTTP " + resp.code() + " " + text, resp.code());
|
||||
}
|
||||
return gson.fromJson(text, JsonObject.class);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, step + " 请求失败", e);
|
||||
if (e instanceof ApiException) throw (ApiException) e;
|
||||
throw new ApiException(e.getMessage(), -1);
|
||||
}
|
||||
}
|
||||
|
||||
/** API 调用异常,携带 HTTP 状态码(401 表示令牌失效)。 */
|
||||
public static class ApiException extends RuntimeException {
|
||||
public final int httpCode;
|
||||
public ApiException(String msg, int code) {
|
||||
super(msg);
|
||||
this.httpCode = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
@@ -12,28 +17,48 @@ import okhttp3.Response;
|
||||
import okhttp3.WebSocket;
|
||||
import okhttp3.WebSocketListener;
|
||||
|
||||
/**
|
||||
* 主控端信令 WebSocket 客户端。
|
||||
*
|
||||
* 鉴权方式:通过 OkHttp 在握手请求头中携带 `Authorization: Bearer <accessToken>`。
|
||||
* 不再发送 REGISTER —— 连接由服务端根据令牌身份自动完成,并下发 REGISTER_SUCCESS(含 fromDeviceId)。
|
||||
*
|
||||
* 关闭码语义:
|
||||
* - 4001 令牌失效:调用方应刷新 accessToken 后重连;
|
||||
* - 4003 强制下线:停止重连,回到登录界面。
|
||||
*/
|
||||
public class WebSocketClient {
|
||||
|
||||
private static final String TAG = "WebSocketClient";
|
||||
|
||||
private final String serverUrl;
|
||||
private final String deviceId;
|
||||
private final String token;
|
||||
private final Gson gson = new Gson();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
|
||||
private OkHttpClient client;
|
||||
private WebSocket webSocket;
|
||||
private SignalListener listener;
|
||||
private int reconnectAttempts = 0;
|
||||
private boolean manualClose = false;
|
||||
private static final int MAX_RECONNECT_DELAY = 30_000;
|
||||
|
||||
public interface SignalListener {
|
||||
void onRegistered(String fromDeviceId);
|
||||
void onConnected();
|
||||
void onDisconnected();
|
||||
void onError(String error);
|
||||
void onMessage(SignalMessage message);
|
||||
/** 令牌失效(关闭码 4001),需刷新令牌后重连。 */
|
||||
void onTokenExpired();
|
||||
/** 强制下线(关闭码 4003),需停止重连并跳登录。 */
|
||||
void onForceLogout();
|
||||
}
|
||||
|
||||
public WebSocketClient(String serverUrl, String deviceId) {
|
||||
public WebSocketClient(String serverUrl, String token) {
|
||||
this.serverUrl = serverUrl;
|
||||
this.deviceId = deviceId;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public void setListener(SignalListener listener) {
|
||||
@@ -41,14 +66,27 @@ public class WebSocketClient {
|
||||
}
|
||||
|
||||
public void connect() {
|
||||
client = new OkHttpClient.Builder().build();
|
||||
Request request = new Request.Builder().url(serverUrl).build();
|
||||
manualClose = false;
|
||||
if (serverUrl == null || serverUrl.isEmpty()) {
|
||||
if (listener != null) listener.onError("服务器地址为空");
|
||||
return;
|
||||
}
|
||||
client = new OkHttpClient.Builder()
|
||||
.pingInterval(20, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(serverUrl);
|
||||
if (token != null && !token.isEmpty()) {
|
||||
reqBuilder.addHeader("Authorization", "Bearer " + token);
|
||||
}
|
||||
Request request = reqBuilder.build();
|
||||
|
||||
webSocket = client.newWebSocket(request, new WebSocketListener() {
|
||||
@Override
|
||||
public void onOpen(WebSocket ws, Response response) {
|
||||
Log.i(TAG, "WebSocket connected");
|
||||
registerDevice();
|
||||
reconnectAttempts = 0;
|
||||
// 不再发送 REGISTER,服务端根据 Bearer 令牌自动注册。
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onConnected();
|
||||
});
|
||||
@@ -59,6 +97,13 @@ public class WebSocketClient {
|
||||
Log.d(TAG, "Received: " + text);
|
||||
try {
|
||||
SignalMessage message = gson.fromJson(text, SignalMessage.class);
|
||||
if (message != null && "REGISTER_SUCCESS".equals(message.getType())
|
||||
&& message.getFromDeviceId() != null) {
|
||||
final String fromDeviceId = message.getFromDeviceId();
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onRegistered(fromDeviceId);
|
||||
});
|
||||
}
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onMessage(message);
|
||||
});
|
||||
@@ -67,24 +112,66 @@ public class WebSocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosing(WebSocket ws, int code, String reason) {
|
||||
if (code == 4001) {
|
||||
Log.w(TAG, "WebSocket closing 4001 (token expired)");
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onTokenExpired();
|
||||
});
|
||||
ws.close(4001, reason);
|
||||
return;
|
||||
}
|
||||
if (code == 4003) {
|
||||
Log.w(TAG, "WebSocket closing 4003 (force logout)");
|
||||
manualClose = true;
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onForceLogout();
|
||||
});
|
||||
ws.close(4003, reason);
|
||||
return;
|
||||
}
|
||||
ws.close(code, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosed(WebSocket ws, int code, String reason) {
|
||||
Log.i(TAG, "WebSocket closed: " + reason);
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onDisconnected();
|
||||
});
|
||||
if (manualClose) {
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onDisconnected();
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleReconnect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(WebSocket ws, Throwable t, Response response) {
|
||||
Log.e(TAG, "WebSocket error: " + t.getMessage(), t);
|
||||
if (manualClose) {
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onError(t.getMessage());
|
||||
});
|
||||
return;
|
||||
}
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onError(t.getMessage());
|
||||
});
|
||||
handleReconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleReconnect() {
|
||||
reconnectAttempts++;
|
||||
long delay = Math.min((long) Math.pow(2, Math.min(reconnectAttempts, 5)) * 1000, MAX_RECONNECT_DELAY);
|
||||
Log.d(TAG, "第 " + reconnectAttempts + " 次重连,延迟 " + delay + "ms");
|
||||
scheduler.schedule(() -> {
|
||||
if (!manualClose) connect();
|
||||
}, delay, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
public void sendMessage(SignalMessage message) {
|
||||
if (webSocket != null) {
|
||||
String json = gson.toJson(message);
|
||||
@@ -93,24 +180,29 @@ public class WebSocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
public void sendHeartbeat() {
|
||||
if (webSocket != null && client != null && !client.dispatcher().executorService().isShutdown()) {
|
||||
try {
|
||||
JsonObject ping = new JsonObject();
|
||||
ping.addProperty("type", "PING");
|
||||
webSocket.send(ping.toString());
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
manualClose = true;
|
||||
if (webSocket != null) {
|
||||
webSocket.close(1000, "Disconnecting");
|
||||
}
|
||||
if (client != null) {
|
||||
client.dispatcher().executorService().shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private void registerDevice() {
|
||||
SignalMessage registerMsg = new SignalMessage();
|
||||
registerMsg.setType("REGISTER");
|
||||
registerMsg.setFromDeviceId(deviceId);
|
||||
registerMsg.setDeviceType("CONTROLLER");
|
||||
sendMessage(registerMsg);
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return webSocket != null;
|
||||
return webSocket != null && client != null && !client.dispatcher().executorService().isShutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.ttstd.controller.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.security.keystore.KeyGenParameterSpec;
|
||||
import android.security.keystore.KeyProperties;
|
||||
|
||||
import androidx.security.crypto.EncryptedSharedPreferences;
|
||||
import androidx.security.crypto.MasterKey;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.GeneralSecurityException;
|
||||
|
||||
/**
|
||||
* 主控端令牌安全存储。
|
||||
*
|
||||
* 登录得到的 accessToken 与 refreshToken 以密文落盘(EncryptedSharedPreferences)。
|
||||
* refreshToken 为一次性(ses_ 前缀),刷新后服务端可能轮换,需覆盖保存。
|
||||
*/
|
||||
public final class TokenStore {
|
||||
|
||||
private static final String FILE_NAME = "ttstd_controller_tokens";
|
||||
private static final String KEY_ACCESS = "access_token";
|
||||
private static final String KEY_REFRESH = "refresh_token";
|
||||
private static final String KEY_USERNAME = "username";
|
||||
|
||||
private final SharedPreferences sp;
|
||||
|
||||
public TokenStore(Context context) {
|
||||
this.sp = create(context);
|
||||
}
|
||||
|
||||
private static SharedPreferences create(Context context) {
|
||||
try {
|
||||
MasterKey masterKey = new MasterKey.Builder(context)
|
||||
.setKeyGenParameterSpec(
|
||||
new KeyGenParameterSpec.Builder(
|
||||
MasterKey.DEFAULT_MASTER_KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.build())
|
||||
.build();
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
FILE_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);
|
||||
} catch (GeneralSecurityException | IOException e) {
|
||||
return context.getSharedPreferences(FILE_NAME + "_fallback", Context.MODE_PRIVATE);
|
||||
}
|
||||
}
|
||||
|
||||
public void save(String accessToken, String refreshToken, String username) {
|
||||
SharedPreferences.Editor editor = sp.edit();
|
||||
editor.putString(KEY_ACCESS, accessToken);
|
||||
if (refreshToken != null) editor.putString(KEY_REFRESH, refreshToken);
|
||||
if (username != null) editor.putString(KEY_USERNAME, username);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
public void saveAccessToken(String accessToken) {
|
||||
sp.edit().putString(KEY_ACCESS, accessToken).apply();
|
||||
}
|
||||
|
||||
public void saveRefreshToken(String refreshToken) {
|
||||
if (refreshToken != null) sp.edit().putString(KEY_REFRESH, refreshToken).apply();
|
||||
}
|
||||
|
||||
public String getAccessToken() {
|
||||
return sp.getString(KEY_ACCESS, null);
|
||||
}
|
||||
|
||||
public String getRefreshToken() {
|
||||
return sp.getString(KEY_REFRESH, null);
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return sp.getString(KEY_USERNAME, null);
|
||||
}
|
||||
|
||||
public boolean hasTokens() {
|
||||
return sp.getString(KEY_ACCESS, null) != null && sp.getString(KEY_REFRESH, null) != null;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
sp.edit().clear().apply();
|
||||
}
|
||||
}
|
||||
24
WebRTCController/app/src/main/res/layout/dialog_login.xml
Normal file
24
WebRTCController/app/src/main/res/layout/dialog_login.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_username"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:hint="@string/login_username_hint"
|
||||
android:inputType="text"
|
||||
android:autofillHints="username" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_password"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/login_password_hint"
|
||||
android:inputType="textPassword"
|
||||
android:autofillHints="password" />
|
||||
</LinearLayout>
|
||||
@@ -12,4 +12,8 @@
|
||||
<string name="auth_code_hint">请输入动态验证码</string>
|
||||
<string name="auth_password_hint">请输入固定密码</string>
|
||||
<string name="auth_input_hint">请输入验证码或密码</string>
|
||||
|
||||
<!-- 登录 -->
|
||||
<string name="login_username_hint">用户名</string>
|
||||
<string name="login_password_hint">密码</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user