feat(controlled): 实现设备激活与安全认证流程
- 添加API客户端、加密存储和provision/token激活逻辑 - WebSocket改用Bearer令牌认证,移除REGISTER请求 - 设备ID改为服务端下发,支持令牌刷新和强制下线处理 - 新增deviceSecret加密存储和accessToken自动刷新 - 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
@@ -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 ? "状态: 运行中" : "状态: 已停止");
|
||||
|
||||
@@ -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;
|
||||
/** 当前 accessToken(Bearer 握手用),失效后重新换取。 */
|
||||
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) {
|
||||
// 服务端下发本机 deviceId(deviceUid),与本地激活一致,仅做校验/日志。
|
||||
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
|
||||
|
||||
@@ -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 与一次性 deviceSecret(deviceSecret 仅返回这一次,需立即安全落盘)。
|
||||
* - 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user