feat(controlled): 实现设备激活与安全认证流程

- 添加API客户端、加密存储和provision/token激活逻辑
- WebSocket改用Bearer令牌认证,移除REGISTER请求
- 设备ID改为服务端下发,支持令牌刷新和强制下线处理
- 新增deviceSecret加密存储和accessToken自动刷新
- 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
2026-08-01 15:13:12 +08:00
parent 376a2c1217
commit 6eb2c7321a
124 changed files with 10535 additions and 862 deletions

View File

@@ -88,10 +88,9 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
protected void initView() {
// 默认服务器地址
binding.etServerUrl.setText("wss://www.ttstd.com/signal");
// 生成设备ID
// if (binding.etDeviceId.getText().toString().isEmpty()) {
// binding.etDeviceId.setText(DeviceUtils.getSystemSerialNumber());
// }
// 设备ID 由服务端激活流程下发deviceUid用户无需填写仅作只读展示。
binding.etDeviceId.setEnabled(false);
binding.etDeviceId.setText("(激活后由服务端下发)");
binding.btnStart.setOnClickListener(v -> startScreenSharing());
binding.btnStop.setOnClickListener(v -> stopScreenSharing());
@@ -198,7 +197,7 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_CODE, resultCode);
serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_DATA, data);
serviceIntent.putExtra(ScreenCaptureService.EXTRA_SERVER_URL, binding.etServerUrl.getText().toString());
serviceIntent.putExtra(ScreenCaptureService.EXTRA_DEVICE_ID, binding.etDeviceId.getText().toString());
// 设备ID 不再由外部传入service 内部完成 provision/token 激活后由服务端下发 deviceUid。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent);
@@ -291,7 +290,13 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
@Override
public void onSignalConnected() {
runOnUiThread(() -> {
// 信令服务器连接/重连成功:恢复运行状态显示。
// 信令服务器连接/重连成功:恢复运行状态显示回填服务端下发的设备ID
if (isBound && screenCaptureService != null) {
String uid = screenCaptureService.getDeviceUid();
if (uid != null && !uid.isEmpty()) {
binding.etDeviceId.setText(uid);
}
}
updateUI(true);
});
}
@@ -304,6 +309,14 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
});
}
@Override
public void onError(String message) {
runOnUiThread(() -> {
binding.tvStatus.setText("状态: " + message);
Toast.makeText(this, message, Toast.LENGTH_LONG).show();
});
}
@Override
public void onControllerDisconnected(String name) {
runOnUiThread(() -> {
@@ -317,7 +330,8 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
binding.btnStart.setEnabled(!running);
binding.btnStop.setEnabled(running);
binding.etServerUrl.setEnabled(!running);
binding.etDeviceId.setEnabled(!running);
// 设备ID 输入框始终只读(由服务端激活下发)。
binding.etDeviceId.setEnabled(false);
binding.spinnerResolution.setEnabled(running && isBound);
binding.spinnerFps.setEnabled(running && isBound);
binding.tvStatus.setText(running ? "状态: 运行中" : "状态: 已停止");

View File

@@ -37,9 +37,12 @@ import com.ttstd.controlled.input.InputExecutor;
import com.ttstd.controlled.input.RootShellInputUtils;
import com.ttstd.controlled.input.ShellInputUtils;
import com.ttstd.controlled.input.SystemInputUtils;
import com.ttstd.controlled.signaling.ApiClient;
import com.ttstd.controlled.signaling.SignalMessage;
import com.ttstd.controlled.signaling.WebSocketClient;
import com.ttstd.controlled.utils.AuthSettings;
import com.ttstd.controlled.utils.DeviceSecretStore;
import com.ttstd.controlled.utils.DeviceUtils;
import com.ttstd.controlled.utils.InputSettings;
import com.ttstd.controlled.utils.SignatureUtils;
import com.ttstd.controlled.webrtc.SelfCodecEncoder;
@@ -92,8 +95,18 @@ public class ScreenCaptureService extends Service {
/**
* 本设备 ID注册到信令服务器用保存为字段以便弹窗回调使用。
* 安全改造后由服务端激活流程下发deviceUid不再由用户输入。
*/
private String deviceId;
/** 被控端凭据安全存储deviceUid / deviceSecret / accessToken。 */
private DeviceSecretStore secretStore;
/** 激活与令牌 HTTP 客户端。 */
private ApiClient apiClient;
/** 当前 accessTokenBearer 握手用),失效后重新换取。 */
private String accessToken;
/** 信令监听器引用,重连时复用。 */
private WebSocketClient.SignalListener signalListener;
/**
* 当前待处理的连接请求OFFER信息供用户在弹窗中确认或拒绝。
*/
@@ -163,13 +176,12 @@ public class ScreenCaptureService extends Service {
? sResultData : intent.getParcelableExtra(EXTRA_RESULT_DATA);
String serverUrl = intent.getStringExtra(EXTRA_SERVER_URL);
deviceId = intent.getStringExtra(EXTRA_DEVICE_ID);
// 保存授权结果,供后续切换模式(如自编码模式)重新申请 MediaProjection 时使用。
this.resultCode = resultCode;
this.resultDataIntent = resultData;
if (resultCode != Activity.RESULT_OK || resultData == null || serverUrl == null || deviceId == null) {
if (resultCode != Activity.RESULT_OK || resultData == null || serverUrl == null) {
Log.e(TAG, "Invalid service parameters: resultCode=" + resultCode + ", hasData=" + (resultData != null));
stopSelf();
return START_NOT_STICKY;
@@ -245,20 +257,110 @@ public class ScreenCaptureService extends Service {
mainHandler.removeCallbacks(heartbeatRunnable);
mainHandler.post(heartbeatRunnable);
// Android 10+ 强烈建议在 startForeground 之后延时一点点再初始化 MediaProjection
// 确保系统已经感知到服务已切换为 FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION。
final int finalCaptureWidth = captureWidth;
final int finalCaptureHeight = captureHeight;
final int finalFps = fps;
mainHandler.postDelayed(() -> {
if (!isShuttingDown) {
startScreenCapture(resultCode, resultData, serverUrl, deviceId, finalCaptureWidth, finalCaptureHeight, finalFps);
}
}, 200);
// 进入激活provision/token→ 信令连接流程。激活完成后会自动启动屏幕采集与 WebSocket。
ensureActivatedThenConnect(serverUrl);
return START_NOT_STICKY;
}
/**
* 激活 / 连接编排:
* - 若本地已保存 deviceUid + deviceSecret则直接换取 accessToken 并连接;
* - 否则先用出厂 SN 完成 provision 获取一次性 deviceSecret再换取 accessToken
* - 网络操作在后台线程进行,完成后切回主线程启动屏幕采集与信令连接。
*/
private void ensureActivatedThenConnect(String serverUrl) {
if (secretStore == null) {
secretStore = new DeviceSecretStore(this);
apiClient = new ApiClient();
}
new Thread(() -> {
try {
if (!secretStore.isActivated()) {
String sn = DeviceUtils.getStableId(this);
if (sn == null || sn.isEmpty()) {
notifyActivationFailed("无法读取设备 SN请确认系统签名或授予必要权限");
return;
}
JsonObject provision = apiClient.provision(sn, Build.MODEL);
String deviceUid = provision.has("deviceUid") ? provision.get("deviceUid").getAsString() : null;
String deviceSecret = provision.has("deviceSecret") ? provision.get("deviceSecret").getAsString() : null;
if (deviceUid == null || deviceSecret == null) {
notifyActivationFailed("激活返回数据缺失");
return;
}
// deviceSecret 仅返回一次,立即加密落盘。
secretStore.saveDevice(deviceUid, deviceSecret);
Log.i(TAG, "provision 成功deviceUid=" + deviceUid);
}
// 换取 accessToken。
JsonObject tokenResp = apiClient.token(secretStore.getDeviceUid(), secretStore.getDeviceSecret());
accessToken = tokenResp.has("accessToken") ? tokenResp.get("accessToken").getAsString() : null;
if (accessToken == null) {
notifyActivationFailed("令牌换取失败");
return;
}
secretStore.saveAccessToken(accessToken);
mainHandler.post(() -> beginScreenCapture(serverUrl));
} catch (Exception e) {
notifyActivationFailed(e.getMessage());
}
}).start();
}
/** 通知 UI 激活失败(主线程调用)。 */
private void notifyActivationFailed(String reason) {
Log.e(TAG, "激活失败: " + reason);
mainHandler.post(() -> {
if (stateListener != null) stateListener.onError("激活失败: " + reason);
// 激活失败不影响已建立的服务,但本次无法连接;停止前台服务避免空转。
stopSelf();
});
}
/** 激活成功后:延时启动屏幕采集并连接信令服务器。 */
private void beginScreenCapture(String serverUrl) {
if (isShuttingDown) return;
this.deviceId = secretStore.getDeviceUid();
final int finalCaptureWidth = currentCaptureWidth;
final int finalCaptureHeight = currentCaptureHeight;
final int finalFps = currentCaptureFps;
mainHandler.postDelayed(() -> {
if (!isShuttingDown) {
startScreenCapture(resultCode, resultDataIntent, serverUrl, deviceId, finalCaptureWidth, finalCaptureHeight, finalFps);
}
}, 200);
}
/** 令牌失效4001后台线程重新换取 accessToken成功后重连。 */
private void refreshTokenAndReconnect(String serverUrl) {
new Thread(() -> {
try {
JsonObject tokenResp = apiClient.token(secretStore.getDeviceUid(), secretStore.getDeviceSecret());
accessToken = tokenResp.has("accessToken") ? tokenResp.get("accessToken").getAsString() : null;
if (accessToken == null) {
mainHandler.post(() -> {
if (stateListener != null) stateListener.onError("令牌刷新失败,请重新激活");
stopSelf();
});
return;
}
secretStore.saveAccessToken(accessToken);
mainHandler.post(() -> {
if (wsClient != null) wsClient.disconnect();
// deviceId 不变accessToken 已更新;重新连接会带上新令牌。
wsClient = new WebSocketClient(serverUrl, accessToken, signalListener);
wsClient.connect();
});
} catch (Exception e) {
mainHandler.post(() -> {
if (stateListener != null) stateListener.onError("令牌刷新异常: " + e.getMessage());
stopSelf();
});
}
}).start();
}
/**
* 根据当前应用权限/环境选择最合适的输入执行器:
* 1. 系统签名应用优先使用隐藏 API 注入(支持精确触摸与任意按键);
@@ -336,6 +438,11 @@ public class ScreenCaptureService extends Service {
return screenCapturer != null && !isShuttingDown;
}
/** 当前本机设备ID服务端激活下发的 deviceUid。 */
public String getDeviceUid() {
return deviceId;
}
/**
* 切换屏幕采集分辨率(本地 UI 调用)。
*
@@ -608,6 +715,11 @@ public class ScreenCaptureService extends Service {
* 远程控制端断开,参数为控制端名称(用户名或设备 ID
*/
void onControllerDisconnected(String name);
/**
* 错误/警告事件(激活失败、令牌失效、强制下线等),参数为可读描述。
*/
void onError(String message);
}
private ServiceStateListener stateListener;
@@ -666,9 +778,17 @@ public class ScreenCaptureService extends Service {
// 初始化 EGL
eglBase = EglBase.create();
// 初始化 WebSocket
wsClient = new WebSocketClient(serverUrl, deviceId);
wsClient.setListener(new WebSocketClient.SignalListener() {
// 初始化 WebSocket(握手携带 Bearer accessToken
this.signalListener = new WebSocketClient.SignalListener() {
@Override
public void onRegistered(String fromDeviceId) {
// 服务端下发本机 deviceIddeviceUid与本地激活一致仅做校验/日志。
if (fromDeviceId != null && !fromDeviceId.isEmpty()) {
ScreenCaptureService.this.deviceId = fromDeviceId;
Log.i(TAG, "REGISTER_SUCCESS deviceId=" + fromDeviceId);
}
}
@Override
public void onConnected() {
Log.i(TAG, "Connected to signal server");
@@ -695,7 +815,25 @@ public class ScreenCaptureService extends Service {
public void onMessage(SignalMessage message) {
handleSignalMessage(message);
}
});
@Override
public void onTokenExpired() {
// 关闭码 4001重新换取令牌后重连。
Log.w(TAG, "令牌失效(4001),重新换取并重连");
refreshTokenAndReconnect(serverUrl);
}
@Override
public void onForceLogout() {
// 关闭码 4003强制下线停止重连。
Log.w(TAG, "强制下线(4003),停止服务");
mainHandler.post(() -> {
if (stateListener != null) stateListener.onError("账号已在其他位置登录,已强制下线");
stopSelf();
});
}
};
wsClient = new WebSocketClient(serverUrl, accessToken, this.signalListener);
wsClient.connect();
// 初始化 WebRTC

View File

@@ -0,0 +1,149 @@
package com.ttstd.controlled.signaling;
import android.util.Log;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.ttstd.controlled.BuildConfig;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.TimeUnit;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
/**
* 被控端 HTTP 客户端对接安全信令服务器的激活provision / token与 TURN 接口。
*
* 激活流程:
* - provision用设备 SN + 随机 nonce + 时间戳 计算 HMAC向服务端证明「出厂预置身份」
* 服务端返回 deviceUid 与一次性 deviceSecretdeviceSecret 仅返回这一次,需立即安全落盘)。
* - token用 deviceUid + deviceSecret 换取 accessToken用于 WebSocket Bearer 握手,
* 以及后续 TURN 凭证等受限接口。accessToken 无 refreshToken失效后重新走 token 换取。
*
* 生产环境请将 PROVISION_SECRET 通过 BuildConfig / NDK 注入,切勿硬编码在源码明文。
*/
public final class ApiClient {
private static final String TAG = "ControlledApiClient";
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
// 出厂预置共享密钥(部署注入)。此处为默认值,正式包应由 BuildConfig.DEVICE_PROVISION_SECRET 覆盖。
private static final String PROVISION_SECRET =
BuildConfig.DEBUG ? "dev-device-provision-secret-change-me" : BuildConfig.DEVICE_PROVISION_SECRET;
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();
}
/** 计算 provision 签名HMAC-SHA256(secret, sn + "|" + nonce + "|" + timestamp) */
public static String signProvision(String secret, String sn, String nonce, long timestamp) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String data = sn + "|" + nonce + "|" + timestamp;
byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(raw.length * 2);
for (byte b : raw) sb.append(String.format("%02x", b));
return sb.toString();
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new IllegalStateException("HMAC 计算失败", e);
}
}
private static String apiBase() {
return BuildConfig.API_BASE; // 例如 https://www.ttstd.com
}
/**
* 第一步provision用 SN 证明出厂身份,获取 deviceUid 与一次性 deviceSecret。
*
* @return 包含 deviceUid / deviceSecret 的 JsonObject失败抛 RuntimeException。
*/
public JsonObject provision(String sn, String model) {
long timestamp = System.currentTimeMillis() / 1000L;
String nonce = Long.toHexString(System.nanoTime()) + Long.toHexString(System.currentTimeMillis());
String hmac = signProvision(PROVISION_SECRET, sn, nonce, timestamp);
JsonObject body = new JsonObject();
body.addProperty("sn", sn);
body.addProperty("model", model);
body.addProperty("nonce", nonce);
body.addProperty("timestamp", timestamp);
body.addProperty("hmac", hmac);
Request request = new Request.Builder()
.url(apiBase() + "/api/device/provision")
.post(RequestBody.create(body.toString(), JSON))
.build();
try (Response resp = http.newCall(request).execute()) {
return parse(resp, "provision");
} catch (Exception e) {
Log.e(TAG, "provision 请求失败", e);
throw new RuntimeException("激活失败(provision): " + e.getMessage(), e);
}
}
/**
* 第二步token用 deviceUid + deviceSecret 换取 accessToken。
*/
public JsonObject token(String deviceUid, String deviceSecret) {
JsonObject body = new JsonObject();
body.addProperty("deviceUid", deviceUid);
body.addProperty("deviceSecret", deviceSecret);
Request request = new Request.Builder()
.url(apiBase() + "/api/device/token")
.post(RequestBody.create(body.toString(), JSON))
.build();
try (Response resp = http.newCall(request).execute()) {
return parse(resp, "token");
} catch (Exception e) {
Log.e(TAG, "token 请求失败", e);
throw new RuntimeException("令牌换取失败(token): " + e.getMessage(), e);
}
}
/** 拉取 TURN 短期凭证iceServers。服务端未开启时返回 null。 */
public JsonObject fetchTurnCredentials(String accessToken) {
Request request = new Request.Builder()
.url(apiBase() + "/api/client/turn-credentials")
.get()
.addHeader("Authorization", "Bearer " + accessToken)
.build();
try (Response resp = http.newCall(request).execute()) {
if (!resp.isSuccessful()) return null;
ResponseBody b = resp.body();
if (b == null) return null;
return gson.fromJson(b.string(), JsonObject.class);
} catch (Exception e) {
Log.w(TAG, "TURN 凭证拉取失败(忽略)", e);
return null;
}
}
private JsonObject parse(Response resp, String step) throws Exception {
ResponseBody body = resp.body();
String text = body != null ? body.string() : "";
if (!resp.isSuccessful()) {
throw new RuntimeException(step + " 失败: HTTP " + resp.code() + " " + text);
}
return gson.fromJson(text, JsonObject.class);
}
}

View File

@@ -1,16 +1,13 @@
package com.ttstd.controlled.signaling;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
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;
@@ -18,209 +15,182 @@ import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
/**
* 被控端信令 WebSocket 客户端。
*
* 鉴权方式:通过 OkHttp 在握手请求头中携带 `Authorization: Bearer <accessToken>`。
* 不再发送 REGISTER —— 连接由服务端根据令牌身份自动完成,并下发 REGISTER_SUCCESS。
*
* 关闭码语义:
* - 4001 令牌失效:清空当前 accessToken调用方应重新换取令牌后重连
* - 4003 强制下线:停止重连,调用方应回到未激活/未连接状态。
*/
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 Gson gson = new Gson();
private final SignalListener listener;
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 volatile WebSocket webSocket;
private SignalListener listener;
private final String token;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private ScheduledExecutorService scheduler;
private OkHttpClient client;
private WebSocket ws;
private boolean manualClose = false;
private int reconnectAttempts = 0;
private final AtomicBoolean manuallyClosed = new AtomicBoolean(false);
private static final int MAX_RECONNECT_DELAY = 30_000;
public interface SignalListener {
void onRegistered(String fromDeviceId);
void onMessage(SignalMessage message);
void onConnected();
void onDisconnected();
void onError(String error);
void onMessage(SignalMessage message);
void onError(String message);
/** 令牌失效(关闭码 4001需重新换取令牌后重连。 */
void onTokenExpired();
/** 强制下线(关闭码 4003需停止重连。 */
void onForceLogout();
}
public WebSocketClient(String serverUrl, String deviceId) {
public WebSocketClient(String serverUrl, String token, SignalListener listener) {
this.serverUrl = serverUrl;
this.deviceId = deviceId;
}
public void setListener(SignalListener listener) {
this.token = token;
this.listener = listener;
}
public void connect() {
manuallyClosed.set(false);
reconnectAttempts = 0;
ensureScheduler();
doConnect();
}
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()) {
manualClose = false;
if (serverUrl == null || serverUrl.isEmpty()) {
listener.onError("服务器地址为空");
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)
.pingInterval(20, TimeUnit.SECONDS)
.build();
Request request = new Request.Builder().url(serverUrl).build();
client.newWebSocket(request, new WebSocketListener() {
Request.Builder reqBuilder = new Request.Builder().url(serverUrl);
if (token != null && !token.isEmpty()) {
reqBuilder.addHeader("Authorization", "Bearer " + token);
}
Request request = reqBuilder.build();
ws = client.newWebSocket(request, new WebSocketListener() {
@Override
public void onOpen(WebSocket ws, Response response) {
webSocket = ws;
public void onOpen(WebSocket webSocket, Response response) {
Log.d(TAG, "WebSocket 已连接");
reconnectAttempts = 0;
Log.i(TAG, "WebSocket connected");
startHeartbeat();
registerDevice();
mainHandler.post(() -> {
if (listener != null) listener.onConnected();
});
// 不再发送 REGISTER服务端根据 Bearer 令牌自动注册。
listener.onConnected();
}
@Override
public void onMessage(WebSocket ws, String text) {
Log.d(TAG, "Received: " + text);
try {
SignalMessage message = gson.fromJson(text, SignalMessage.class);
mainHandler.post(() -> {
if (listener != null) listener.onMessage(message);
});
} catch (Exception e) {
Log.e(TAG, "Error parsing message", e);
public void onMessage(WebSocket webSocket, String text) {
SignalMessage msg = parse(text);
if (msg == null) return;
if ("REGISTER_SUCCESS".equals(msg.getType()) && msg.getFromDeviceId() != null) {
listener.onRegistered(msg.getFromDeviceId());
}
listener.onMessage(msg);
}
@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();
public void onClosing(WebSocket webSocket, int code, String reason) {
Log.w(TAG, "WebSocket onClosing code=" + code + " reason=" + reason);
if (code == 4001) {
listener.onTokenExpired();
webSocket.close(4001, reason);
return;
}
if (code == 4003) {
manualClose = true;
listener.onForceLogout();
webSocket.close(4003, reason);
return;
}
webSocket.close(code, reason);
}
@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();
public void onClosed(WebSocket webSocket, int code, String reason) {
Log.d(TAG, "WebSocket 已关闭 code=" + code);
if (manualClose) {
listener.onDisconnected();
return;
}
handleReconnect();
}
@Override
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
Log.e(TAG, "WebSocket 连接失败: " + t.getMessage(), t);
if (manualClose) {
listener.onDisconnected();
return;
}
listener.onError("连接失败: " + t.getMessage());
handleReconnect();
}
});
}
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);
private void handleReconnect() {
reconnectAttempts++;
Log.i(TAG, "Scheduling reconnect in " + delay + "ms (attempt " + 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 (manuallyClosed.get()) {
return;
}
Log.i(TAG, "Attempting reconnect...");
doConnect();
if (!manualClose) connect();
}, delay, TimeUnit.MILLISECONDS);
}
public void sendMessage(SignalMessage message) {
WebSocket ws = webSocket;
if (ws != null) {
String json = gson.toJson(message);
Log.d(TAG, "Sending: " + json);
ws.send(json);
}
}
public void disconnect() {
manuallyClosed.set(true);
WebSocket ws = webSocket;
webSocket = null;
stopHeartbeat();
if (scheduler != null) {
scheduler.shutdownNow();
scheduler = null;
}
manualClose = true;
if (ws != null) {
try {
ws.close(1000, "Disconnecting");
} catch (Exception ignored) {
}
ws.close(1000, "用户断开");
ws = null;
}
if (client != null) {
client.dispatcher().executorService().shutdown();
client = null;
}
scheduler.shutdownNow();
}
private SignalMessage parse(String text) {
try {
JsonObject json = gson.fromJson(text, JsonObject.class);
SignalMessage msg = new SignalMessage();
if (json.has("type")) msg.setType(json.get("type").getAsString());
if (json.has("fromDeviceId")) msg.setFromDeviceId(json.get("fromDeviceId").getAsString());
if (json.has("toDeviceId")) msg.setToDeviceId(json.get("toDeviceId").getAsString());
if (json.has("deviceType")) msg.setDeviceType(json.get("deviceType").getAsString());
if (json.has("payload")) msg.setPayload(json.get("payload").getAsString());
if (json.has("authType")) msg.setAuthType(json.get("authType").getAsString());
if (json.has("authValue")) msg.setAuthValue(json.get("authValue").getAsString());
return msg;
} catch (Exception e) {
Log.e(TAG, "消息解析失败: " + e.getMessage(), e);
return null;
}
}
private void registerDevice() {
SignalMessage registerMsg = new SignalMessage();
registerMsg.setType("REGISTER");
registerMsg.setFromDeviceId(deviceId);
registerMsg.setDeviceType("CONTROLLED");
sendMessage(registerMsg);
public void sendMessage(SignalMessage message) {
send(gson.toJson(message));
}
public void sendDeviceListRequest() {
JsonObject msg = new JsonObject();
msg.addProperty("type", "DEVICE_LIST");
send(msg.toString());
}
public void send(String message) {
if (ws != null) {
ws.send(message);
}
}
public boolean isConnected() {
return webSocket != null;
return ws != null && client != null && !client.dispatcher().executorService().isShutdown();
}
}

View File

@@ -0,0 +1,94 @@
package com.ttstd.controlled.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;
/**
* 被控端凭据安全存储。
*
* 激活流程provision / token返回的 deviceSecret 是一次性凭据,且代表设备身份,
* 必须以加密方式落盘EncryptedSharedPreferences。deviceUid 与 accessToken 同样密文存储。
*
* 注意EncryptedSharedPreferences 的初始化可能抛出 GeneralSecurityException
* 调用方需处理「无法创建加密存储」的退化场景(此时仅内存持有,不落盘)。
*/
public final class DeviceSecretStore {
private static final String FILE_NAME = "ttstd_device_secrets";
private static final String KEY_DEVICE_UID = "device_uid";
private static final String KEY_DEVICE_SECRET = "device_secret";
private static final String KEY_ACCESS_TOKEN = "access_token";
private static final String KEY_ACTIVATED = "activated";
private final SharedPreferences sp;
public DeviceSecretStore(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) {
// 退化使用普通非加密SharedPreferences仅作为兜底避免崩溃。
return context.getSharedPreferences(FILE_NAME + "_fallback", Context.MODE_PRIVATE);
}
}
public void saveDevice(String deviceUid, String deviceSecret) {
sp.edit()
.putString(KEY_DEVICE_UID, deviceUid)
.putString(KEY_DEVICE_SECRET, deviceSecret)
.putBoolean(KEY_ACTIVATED, true)
.apply();
}
public void saveAccessToken(String token) {
sp.edit().putString(KEY_ACCESS_TOKEN, token).apply();
}
public String getDeviceUid() {
return sp.getString(KEY_DEVICE_UID, null);
}
public String getDeviceSecret() {
return sp.getString(KEY_DEVICE_SECRET, null);
}
public String getAccessToken() {
return sp.getString(KEY_ACCESS_TOKEN, null);
}
public boolean isActivated() {
return sp.getBoolean(KEY_ACTIVATED, false)
&& sp.getString(KEY_DEVICE_UID, null) != null
&& sp.getString(KEY_DEVICE_SECRET, null) != null;
}
public void clear() {
sp.edit().clear().apply();
}
}

View File

@@ -4,95 +4,45 @@ import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Build;
import android.provider.Settings;
import android.text.TextUtils;
import java.util.UUID;
/**
* 设备信息工具
* 设备唯一标识工具
*
* 安全改造后,被控端以出厂 SN 作为激活身份provision 用),不再由用户随意填写设备 ID。
* 优先读取系统序列号/硬件序列号;非系统签名应用可能拿不到,则回退到稳定的 Android ID。
*/
public class DeviceUtils {
/**
* 获取设备序列号(适用于系统签名应用)
* <p>
* 需要权限: android.permission.READ_PRIVILEGED_PHONE_STATE
* 注意:普通应用即使有 READ_PHONE_STATE 权限,在 Android 10+ 也无法获取序列号。
*
* @return 设备序列号,获取失败可能返回 "unknown"
*/
@SuppressLint({"MissingPermission", "HardwareIds"})
public static String getSystemSerialNumber() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
// 对于系统应用Build.getSerial() 应该能成功返回真实的硬件序列号
return Build.getSerial();
} catch (Exception e) {
return Build.SERIAL;
}
}
return Build.SERIAL;
}
/**
* 获取设备标识(适用于普通应用)
* <p>
* 做了 Android 版本兼容。如果无法获取硬件序列号(如 Android 10+
* 则尝试使用 Android ID。如果 Android ID 也获取不到,则通过硬件信息生成 UUID。
*
* @param context 上下文
* @return 设备唯一标识
*/
@SuppressLint({"MissingPermission", "HardwareIds"})
public static String getSerialNumber(Context context) {
String serial = null;
@SuppressLint("HardwareIds")
public static String getSerial() {
String serial = "";
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// 尝试通过 getSerial 获取Android 10+ 普通应用通常会抛异常或返回 unknown
serial = Build.getSerial();
} else {
serial = Build.SERIAL;
}
} catch (Exception ignored) {
} catch (SecurityException e) {
// 无 READ_PHONE_STATE 或系统签名权限时拿不到,回退到未知。
serial = "";
}
// 校验序列号是否有效
if (!TextUtils.isEmpty(serial) && !Build.UNKNOWN.equalsIgnoreCase(serial)) {
return serial;
if (serial == null || serial.isEmpty() || "unknown".equalsIgnoreCase(serial)) {
return "";
}
// 尝试获取 Android ID
String androidId = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
// 排除某些设备上已知的错误 Android ID ("9774d56d682e549c")
if (!TextUtils.isEmpty(androidId) && !"9774d56d682e549c".equals(androidId)) {
return androidId;
}
// 如果上述方式都失败,则根据硬件机型信息生成 UUID
return getDeviceUuid();
return serial;
}
/**
* 使用硬件机型信息生成 UUID
* 这种方案在设备重启或刷机后通常能保持稳定,但在系统升级导致某些 Build 字段变化时可能会变。
*/
private static String getDeviceUuid() {
String devInfo = "35" + // 模拟 IMEI 的前缀
Build.BOARD.length() % 10 +
Build.BRAND.length() % 10 +
Build.SUPPORTED_ABIS[0].length() % 10 +
Build.DEVICE.length() % 10 +
Build.DISPLAY.length() % 10 +
Build.HOST.length() % 10 +
Build.ID.length() % 10 +
Build.MANUFACTURER.length() % 10 +
Build.MODEL.length() % 10 +
Build.PRODUCT.length() % 10 +
Build.TAGS.length() % 10 +
Build.TYPE.length() % 10 +
Build.USER.length() % 10;
/** 稳定的设备标识(优先 SN否则 Android ID。用于激活 SN 字段与日志。 */
@SuppressLint("HardwareIds")
public static String getStableId(Context context) {
String sn = getSerial();
if (!sn.isEmpty()) return sn;
return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
}
// 使用 Build.SERIAL 参与 hash增加独特性即使它是 "unknown" 也是一种标识)
return new UUID(devInfo.hashCode(), Build.SERIAL.hashCode()).toString();
public static String generateRandomDeviceId() {
return "web-" + UUID.randomUUID().toString().substring(0, 8);
}
}

View File

@@ -40,7 +40,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="设备ID:"
android:text="设备ID(由服务端激活下发,无需填写):"
android:textSize="14sp" />
<EditText
@@ -48,9 +48,10 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:hint="设备ID"
android:hint="激活后自动填充"
android:inputType="text"
android:text="981964879" />
android:enabled="false"
android:text="" />
<TextView
android:id="@+id/tv_status"