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

@@ -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'
// ProtobufDataChannel 控制指令二进制)
implementation 'com.google.protobuf:protobuf-java:3.25.1'
// 安全存储:加密 SharedPreferences保存 accessToken / refreshToken
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
}

View File

@@ -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) {
// 服务端下发本机 deviceIdCONTROLLER用于 WebRTC Offer 标识。
myDeviceId = fromDeviceId;
etDeviceId.setText(fromDeviceId);
tvStatus.setText("状态: 已注册 (" + fromDeviceId + "),正在发起连接...");
// 注册成功后初始化 WebRTC 并创建 OffermyDeviceId 此时已就绪)。
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;

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View 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>

View File

@@ -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>