feat: 重构为前后端分离架构并完善设备端演示
- 新增 Web 用户端(登录体系 + 统一 REST API 调用) - 后端增加用户认证、统一 ApiResponse、CORS 支持 - Android 设备端迁移至 MVVM + DataBinding + Retrofit 网络层 - 完善 README 架构说明与密码学原理文档 - 新增 .gitignore 与持久化数据表说明
This commit is contained in:
@@ -12,6 +12,7 @@ android {
|
||||
targetSdkVersion 36
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -21,10 +22,21 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
dataBinding true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
|
||||
// 网络层(技术栈对齐 WebRTCController:Retrofit + OkHttp + Gson + RxJava3)
|
||||
implementation 'com.squareup.retrofit2:retrofit:3.0.0'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:3.0.0'
|
||||
implementation 'com.squareup.retrofit2:adapter-rxjava3:3.0.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.3.2'
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor:5.3.2'
|
||||
implementation 'io.reactivex.rxjava3:rxjava:3.1.12'
|
||||
implementation 'io.reactivex.rxjava3:rxandroid:3.0.2'
|
||||
implementation 'com.google.code.gson:gson:2.14.0'
|
||||
}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- 网络请求权限 -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!-- 文件读写权限(分区存储兼容) -->
|
||||
<!-- Android 13+ 细粒度媒体权限 -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<!-- Android 9- 旧式存储权限(maxSdk 限制为 Q 之前,Q+ 走分区存储 / SAF) -->
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.SecureDevice">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:name=".activity.main.MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package com.secure.demo;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.widget.TextView;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import com.secure.device.DeviceCrypto;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 最精简演示:在 KeyStore(TEE) 中生成 RSA-2048 密钥对,
|
||||
* 完成「信封加密 → 解密还原」与「元数据签名 → 公钥验签」全流程,
|
||||
* 无需任何后端即可在真机/模拟器上验证 DeviceCrypto 可用。
|
||||
*/
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
TextView tv = findViewById(R.id.log);
|
||||
|
||||
new Thread(() -> {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
try {
|
||||
DeviceCrypto crypto = new DeviceCrypto(this);
|
||||
|
||||
String pub = crypto.getPublicKeyBase64();
|
||||
sb.append("设备公钥(前48字符):\n").append(pub, 0, Math.min(48, pub.length()))
|
||||
.append("...\n\n");
|
||||
|
||||
// 1) 信封加密(模拟拍照)
|
||||
String plaintext = "私密照片数据 Hello TEE!";
|
||||
DeviceCrypto.EncryptedPayload p = crypto.encryptData(plaintext.getBytes("UTF-8"));
|
||||
sb.append("原始数据: ").append(plaintext).append("\n");
|
||||
sb.append("密文: ").append(p.ciphertextBase64, 0, Math.min(32, p.ciphertextBase64.length()))
|
||||
.append("...\n");
|
||||
sb.append("IV: ").append(p.ivBase64).append("\n");
|
||||
sb.append("DEK(明文, 实际应仅经HTTPS给服务端): ")
|
||||
.append(p.dekBase64, 0, Math.min(24, p.dekBase64.length())).append("...\n\n");
|
||||
|
||||
// 2) 服务端用 UK 加密 DEK 后下发,此处用相同 DEK 还原,验证解密链路
|
||||
byte[] dekBytes = Base64.getDecoder().decode(p.dekBase64);
|
||||
byte[] decrypted = crypto.decryptPhoto(p.ciphertextBase64, p.ivBase64, dekBytes);
|
||||
sb.append("解密还原: ").append(new String(decrypted, "UTF-8")).append("\n\n");
|
||||
|
||||
// 3) 元数据签名 + 公钥验签
|
||||
String metadata = crypto.getDeviceSN() + "|" + System.currentTimeMillis() + "|photo001";
|
||||
String sig = crypto.signMetadata(metadata);
|
||||
sb.append("元数据: ").append(metadata).append("\n");
|
||||
sb.append("签名(前32字符): ").append(sig, 0, Math.min(32, sig.length())).append("...\n");
|
||||
boolean ok = verify(metadata, sig, pub);
|
||||
sb.append("验签结果: ").append(ok ? "通过 ✅" : "失败 ❌").append("\n");
|
||||
} catch (Exception e) {
|
||||
sb.append("异常: ").append(e).append("\n");
|
||||
e.printStackTrace();
|
||||
}
|
||||
final String text = sb.toString();
|
||||
runOnUiThread(() -> tv.setText(text));
|
||||
}).start();
|
||||
}
|
||||
|
||||
private boolean verify(String metadata, String sigBase64, String pubKeyBase64) throws Exception {
|
||||
java.security.PublicKey pub = KeyFactory.getInstance("RSA")
|
||||
.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(pubKeyBase64)));
|
||||
Signature s = Signature.getInstance("SHA256withRSA");
|
||||
s.initVerify(pub);
|
||||
s.update(metadata.getBytes("UTF-8"));
|
||||
return s.verify(Base64.getDecoder().decode(sigBase64));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package com.secure.demo.activity.main;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.text.Layout;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.secure.demo.R;
|
||||
import com.secure.demo.base.mvvm.BaseMvvmActivity;
|
||||
import com.secure.demo.databinding.ActivityMainBinding;
|
||||
import com.secure.demo.network.ApiClient;
|
||||
import com.secure.demo.util.PermissionHelper;
|
||||
import com.secure.demo.util.SafPicker;
|
||||
|
||||
/**
|
||||
* 主界面(标准 MVVM)—— 模拟真实用户操作。
|
||||
*
|
||||
* 视图层仅负责:
|
||||
* - 渲染 ViewModel 暴露的 LiveData(日志 / 忙状态 / 提示 / 状态摘要 / 选中图片预览);
|
||||
* - 处理运行权限(兼容 Android 10+ 分区存储);
|
||||
* - 通过 SAF 拉起系统图片选择器(免权限、可持久化 URI);
|
||||
* - 把 9 个真实操作按钮的点击与输入框内容转发给 ViewModel,不持有业务逻辑。
|
||||
*
|
||||
* 所有 View 均通过 DataBinding({@code ActivityMainBinding})访问,不使用 findViewById。
|
||||
*
|
||||
* 不再自动跑一键演示链路:打开页面仅做本地 TEE 初始化(模拟设备开机),
|
||||
* 之后每一步(注册/绑定/上传/下载/恢复出厂/重新注册/短信/恢复授权/恢复照片)
|
||||
* 均由用户手动触发,模拟真实操作流程。
|
||||
*/
|
||||
public class MainActivity extends BaseMvvmActivity<ActivityMainBinding, MainViewModel> {
|
||||
|
||||
private static final int REQ_STORAGE_PERMISSION = 1001;
|
||||
|
||||
private SafPicker safPicker;
|
||||
|
||||
@Override
|
||||
protected int getLayoutId() {
|
||||
return R.layout.activity_main;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<MainViewModel> getViewModelClass() {
|
||||
return MainViewModel.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onReady(@Nullable Bundle savedInstanceState) {
|
||||
// 网络栈初始化(必须在首次发起请求前调用,否则 deviceApi() 会抛异常)
|
||||
ApiClient.init(getApplicationContext());
|
||||
|
||||
// 把每次网络请求(method/url/状态码/耗时)实时显示到界面日志区
|
||||
ApiClient.setHttpLogListener(msg -> viewModel.ui.appendLog(msg));
|
||||
|
||||
observeViewModel();
|
||||
// 将点击处理器注入 DataBinding,供布局中的 android:onClick="@{click::xxx}" 方法引用调用
|
||||
binding.setClick(new ViewClick());
|
||||
setupLogScrolling();
|
||||
|
||||
// SAF 选择器(生命周期安全的 ActivityResult 注册)
|
||||
safPicker = new SafPicker(this, uri -> viewModel.uploadSelectedImage(uri));
|
||||
|
||||
// 模拟设备开机:仅做本地 TEE 自检,不自动跑网络链路。
|
||||
// 必须在 observeViewModel() 之后调用,确保 busy 观察者已注册、能正确接收状态变化
|
||||
viewModel.initDevice();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
ApiClient.setHttpLogListener(null);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
/** 解决日志 TextView 在 NestedScrollView 中的滑动冲突:内容可滚动时禁止父级拦截 */
|
||||
private void setupLogScrolling() {
|
||||
binding.log.setMovementMethod(new android.text.method.ScrollingMovementMethod());
|
||||
binding.log.setOnTouchListener((v, event) -> {
|
||||
if (v.getId() == R.id.log) {
|
||||
v.getParent().requestDisallowInterceptTouchEvent(true);
|
||||
if (event.getAction() == MotionEvent.ACTION_UP) {
|
||||
v.getParent().requestDisallowInterceptTouchEvent(false);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/** 观察 UI 状态 LiveData,单向渲染 */
|
||||
private void observeViewModel() {
|
||||
viewModel.ui.getLogLines().observe(this, lines -> {
|
||||
binding.log.setText(lines);
|
||||
binding.log.post(() -> {
|
||||
Layout layout = binding.log.getLayout();
|
||||
if (layout != null) {
|
||||
int scrollAmount = layout.getLineTop(binding.log.getLineCount()) - binding.log.getHeight();
|
||||
if (scrollAmount > 0) {
|
||||
binding.log.scrollTo(0, scrollAmount);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
viewModel.ui.getStatus().observe(this, binding.status::setText);
|
||||
viewModel.ui.getBusy().observe(this, busy -> updateControlsState());
|
||||
viewModel.ui.getCurrentStep().observe(this, step -> updateControlsState());
|
||||
viewModel.ui.getRegistered().observe(this, registered -> updateControlsState());
|
||||
viewModel.ui.getBound().observe(this, bound -> updateControlsState());
|
||||
viewModel.ui.getToast().observe(this, msg -> {
|
||||
if (msg != null) {
|
||||
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
viewModel.ui.getSelectedImageUri().observe(this, uri -> {
|
||||
if (uri != null) {
|
||||
binding.imgPreview.setImageURI(Uri.parse(uri));
|
||||
}
|
||||
});
|
||||
viewModel.ui.getDecryptedImageBase64().observe(this, base64 -> {
|
||||
if (base64 != null && !base64.isEmpty()) {
|
||||
displayDecryptedImage(base64);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将解密后的图片明文(Base64)解码为 Bitmap 并显示到预览区。
|
||||
*
|
||||
* 注意:Bitmap 解码属于 CPU 密集操作,必须放到后台线程执行,避免主线程 ANR。
|
||||
* 解码完成后通过 runOnUiThread 回到主线程更新 ImageView。
|
||||
*/
|
||||
private void displayDecryptedImage(String base64) {
|
||||
java.util.concurrent.ExecutorService exec = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
exec.execute(() -> {
|
||||
try {
|
||||
byte[] bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT);
|
||||
android.graphics.Bitmap bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
|
||||
runOnUiThread(() -> {
|
||||
if (bitmap != null) {
|
||||
binding.imgPreview.setImageBitmap(bitmap);
|
||||
} else {
|
||||
binding.imgPreview.setImageDrawable(null);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
// 非图片明文(如纯文本测试数据)无法解码为 Bitmap,忽略即可
|
||||
} finally {
|
||||
exec.shutdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 综合「忙状态」与「业务进度/注册绑定状态」更新所有控件的可用性 */
|
||||
private void updateControlsState() {
|
||||
boolean isBusy = Boolean.TRUE.equals(viewModel.ui.getBusy().getValue());
|
||||
Integer stepVal = viewModel.ui.getCurrentStep().getValue();
|
||||
int step = stepVal != null ? stepVal : 0;
|
||||
boolean registered = Boolean.TRUE.equals(viewModel.ui.getRegistered().getValue());
|
||||
boolean bound = Boolean.TRUE.equals(viewModel.ui.getBound().getValue());
|
||||
|
||||
// 忙时整体禁用,闲时按「注册/绑定」状态 + 流程进度开启
|
||||
boolean idle = !isBusy;
|
||||
|
||||
// ---- 核心业务按钮:由「注册/绑定」状态驱动(不再依赖单一 step 数字)----
|
||||
// 注册:未注册时可点(注册成功后自动置 registered=true,按钮随之禁用)
|
||||
binding.btnRegister.setEnabled(idle && !registered);
|
||||
// 绑定:已注册但未绑定时可点
|
||||
binding.btnBind.setEnabled(idle && registered && !bound);
|
||||
// 上传图片:已绑定时可点(已绑定设备随时可继续上传,无需回到 step==3)
|
||||
binding.btnUploadImage.setEnabled(idle && bound);
|
||||
// 下载解密:已绑定时可点(ViewModel 内部对「尚未上传」有 toast 兜底)
|
||||
binding.btnDownload.setEnabled(idle && bound);
|
||||
// 恢复出厂:只要初始化完成即可点(本地 TEE 操作,与注册/绑定无关)
|
||||
binding.btnReset.setEnabled(idle);
|
||||
|
||||
// ---- 恢复链路按钮:仍由 step 引导(恢复出厂后的专用流程)----
|
||||
binding.btnReregister.setEnabled(idle && step == 6);
|
||||
binding.btnSms.setEnabled(idle && step == 7);
|
||||
binding.btnRecover.setEnabled(idle && step == 8);
|
||||
binding.btnRecoverPhotos.setEnabled(idle && step == 9);
|
||||
|
||||
// ---- 输入框逻辑 ----
|
||||
// 用户ID/手机号:未绑定时可编辑(首次绑定阶段)
|
||||
binding.userId.setEnabled(idle && !bound);
|
||||
binding.phone.setEnabled(idle && (!bound || step == 7));
|
||||
// 短信验证码:恢复授权阶段可编辑
|
||||
binding.smsCode.setEnabled(idle && step >= 7);
|
||||
|
||||
// ---- 文字反馈 ----
|
||||
binding.btnUploadImage.setText(isBusy && step == 3 ? "上传中..." : "③ 选择并上传图片");
|
||||
if (isBusy) {
|
||||
binding.status.setText("⏳ 正在处理,请稍候...");
|
||||
}
|
||||
}
|
||||
|
||||
/** 权限适配:Android 10+ 无需权限,直接 SAF;旧设备按需申请 */
|
||||
private void ensurePermissionThenPick() {
|
||||
if (PermissionHelper.needsRequestPermission(this)) {
|
||||
requestPermissions(
|
||||
PermissionHelper.getMissingImagePermissions(this)
|
||||
.toArray(new String[0]),
|
||||
REQ_STORAGE_PERMISSION);
|
||||
} else {
|
||||
// Android 10+:分区存储 / SAF 路径,无需危险权限
|
||||
safPicker.openImagePicker();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode,
|
||||
@androidx.annotation.NonNull String[] permissions,
|
||||
@androidx.annotation.NonNull int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
if (requestCode == REQ_STORAGE_PERMISSION) {
|
||||
boolean allGranted = true;
|
||||
for (int r : grantResults) {
|
||||
if (r != android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
allGranted = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allGranted) {
|
||||
safPicker.openImagePicker();
|
||||
} else {
|
||||
// 用户拒绝:仍可走 SAF(仍可访问自己选中的文件),提示并降级
|
||||
Toast.makeText(this, "权限被拒,将使用系统选择器(SAF)方式", Toast.LENGTH_SHORT).show();
|
||||
safPicker.openImagePicker();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击处理器(DataBinding 方法引用式绑定)。
|
||||
*
|
||||
* 布局中以 {@code android:onClick="@{click::methodName}"} 引用这里的方法;
|
||||
* 方法签名必须为 {@code public void xxx(android.view.View)},与
|
||||
* {@link android.view.View.OnClickListener#onClick(View)} 一致。
|
||||
*
|
||||
* 此类为 MainActivity 的非静态内部类,可直接访问外部 Activity 的
|
||||
* {@code binding} / {@code viewModel} 等成员。
|
||||
*/
|
||||
public class ViewClick {
|
||||
|
||||
/** ① 注册设备 */
|
||||
public void registerDevice(View view) {
|
||||
viewModel.registerDevice();
|
||||
}
|
||||
|
||||
/** ② 绑定用户(读取输入框文本) */
|
||||
public void bindUser(View view) {
|
||||
viewModel.bindUser(
|
||||
binding.userId.getText().toString().trim(),
|
||||
binding.phone.getText().toString().trim());
|
||||
}
|
||||
|
||||
/** ③ 选择并上传图片(权限 + SAF 属 UI 职责,由 Activity 处理) */
|
||||
public void uploadImage(View view) {
|
||||
ensurePermissionThenPick();
|
||||
}
|
||||
|
||||
/** ④ 下载解密照片 */
|
||||
public void downloadPhoto(View view) {
|
||||
viewModel.downloadLastPhoto();
|
||||
}
|
||||
|
||||
/** ⑤ 恢复出厂 */
|
||||
public void resetDevice(View view) {
|
||||
viewModel.resetDevice();
|
||||
}
|
||||
|
||||
/** ⑥ 重新注册 */
|
||||
public void reRegisterDevice(View view) {
|
||||
viewModel.reRegisterDevice();
|
||||
}
|
||||
|
||||
/** ⑦ 发送短信验证码(读取手机号) */
|
||||
public void sendSms(View view) {
|
||||
viewModel.sendSms(binding.phone.getText().toString().trim());
|
||||
}
|
||||
|
||||
/** ⑧ 恢复授权(读取短信验证码) */
|
||||
public void recoverDevice(View view) {
|
||||
viewModel.recoverDevice(binding.smsCode.getText().toString().trim());
|
||||
}
|
||||
|
||||
/** ⑨ 恢复照片 */
|
||||
public void recoverPhotos(View view) {
|
||||
viewModel.recoverPhotos();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
package com.secure.demo.activity.main;
|
||||
|
||||
import android.app.Application;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.secure.demo.base.mvvm.BaseViewModel;
|
||||
import com.secure.demo.base.mvvm.UiState;
|
||||
import com.secure.demo.network.ApiClient;
|
||||
import com.secure.demo.network.DeviceApi;
|
||||
import com.secure.demo.network.model.ApiResponse;
|
||||
import com.secure.demo.network.model.BindRequest;
|
||||
import com.secure.demo.network.model.DeviceStatusResponse;
|
||||
import com.secure.demo.network.model.DownloadDecryptResponse;
|
||||
import com.secure.demo.network.model.EncryptedDek;
|
||||
import com.secure.demo.network.model.MessageResponse;
|
||||
import com.secure.demo.network.model.PhotoRecoverRequest;
|
||||
import com.secure.demo.network.model.PhotoRecoverResponse;
|
||||
import com.secure.demo.network.model.RecoverRequest;
|
||||
import com.secure.demo.network.model.RecoverResponse;
|
||||
import com.secure.demo.network.model.RegisterRequest;
|
||||
import com.secure.demo.network.model.RegisterResponse;
|
||||
import com.secure.demo.network.model.SmsRequest;
|
||||
import com.secure.demo.network.model.UploadPhotoRequest;
|
||||
import com.secure.demo.network.model.UploadPhotoResponse;
|
||||
import com.secure.device.DeviceCrypto;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
/**
|
||||
* 主界面 ViewModel(标准 MVVM)。
|
||||
* <p>
|
||||
* 模拟真实用户操作:不再一键自动跑完整链路,而是把整条安全链路拆成
|
||||
* 独立的、由用户逐步手动触发的操作(对应真实使用流程):
|
||||
* <p>
|
||||
* ① 设备注册 → ② 绑定用户 → ③ 加密上传照片 → ④ 下载解密照片
|
||||
* → ⑤ 恢复出厂(销毁 TEE 密钥) → ⑥ 重新注册 → ⑦ 发送短信验证码
|
||||
* → ⑧ 恢复授权(短信+SN归属双因子) → ⑨ 恢复照片(取回 DEK 并还原)。
|
||||
* <p>
|
||||
* 每一步都维护设备状态(SN/公钥/deviceId/userId),并通过 {@link UiState#setStatus(String)}
|
||||
* 实时展示当前设备状态摘要,方便观察操作顺序对状态的影响。
|
||||
*/
|
||||
public class MainViewModel extends BaseViewModel {
|
||||
|
||||
private static final String TAG = "MainViewModel";
|
||||
|
||||
private final Application app;
|
||||
public final UiState ui = new UiState();
|
||||
|
||||
// ==================== 设备/用户状态(跨步骤保持) ====================
|
||||
|
||||
/** 本地 TEE 密钥载体(初始化后保持复用) */
|
||||
private DeviceCrypto crypto;
|
||||
/** 是否已完成开机自检(ViewModel 跨 Activity 重建复用时避免重复初始化) */
|
||||
private boolean initialized;
|
||||
/** 设备 SN(TEE 派生,固定不变) */
|
||||
private String sn;
|
||||
/** 当前设备公钥(恢复出厂后轮换) */
|
||||
private String pubKey;
|
||||
/** 当前后端注册的设备 ID(恢复出厂后重新注册会变更) */
|
||||
private String deviceId;
|
||||
/** 是否已绑定用户(准确追踪绑定态,驱动 UI 按钮可用性) */
|
||||
private boolean bound;
|
||||
/** 当前绑定/操作的用户 */
|
||||
private String userId = "user-001";
|
||||
/** 用户手机号(绑定与短信必须一致,恢复校验依赖) */
|
||||
private String phone = "13800138000";
|
||||
/** 恢复授权后 TEE 解出的明文 Recovery Token(一次性) */
|
||||
private String recoveryToken;
|
||||
|
||||
// ==================== 最近上传照片的密文/IV(下载与恢复还原用) ====================
|
||||
|
||||
private String lastPhotoId;
|
||||
private String lastCiphertextBase64;
|
||||
private String lastIvBase64;
|
||||
|
||||
public MainViewModel(@NonNull Application application) {
|
||||
super(application);
|
||||
this.app = application;
|
||||
}
|
||||
|
||||
// ==================== 通用步骤执行器 ====================
|
||||
|
||||
/**
|
||||
* 以「一步真实操作」为单位执行:忙状态 → 订阅 → 成功回调(主线程) / 失败提示。
|
||||
* 加密/解密等耗时操作请放在 {@code single} 内部并通过 Schedulers.computation() 调度。
|
||||
*/
|
||||
private <T> void runStep(String step, Single<T> single, Consumer<T> onOk) {
|
||||
ui.setBusy(true);
|
||||
Disposable d = single
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
data -> {
|
||||
try {
|
||||
onOk.accept(data);
|
||||
} catch (Exception ex) {
|
||||
Log.e(TAG, step + " 结果处理失败: " + ex.getMessage(), ex);
|
||||
ui.appendLog("❌ " + step + " 结果处理失败: " + ex.getMessage());
|
||||
ui.postToast(step + " 失败: " + ex.getMessage());
|
||||
}
|
||||
ui.setBusy(false);
|
||||
},
|
||||
e -> {
|
||||
Log.e(TAG, step + " 失败: " + e.getMessage(), e);
|
||||
ui.appendLog("❌ " + step + " 失败: " + e.getMessage());
|
||||
ui.postToast(step + " 失败: " + e.getMessage());
|
||||
ui.setBusy(false);
|
||||
}
|
||||
);
|
||||
addDisposable(d);
|
||||
}
|
||||
|
||||
// ==================== 设备开机:本地 TEE 初始化(无网络) ====================
|
||||
|
||||
/**
|
||||
* 模拟设备开机:本地 TEE 自检(生成密钥对、信封加解密自测、元数据签名自测)。
|
||||
* 由视图层在页面就绪后自动调用一次。
|
||||
*/
|
||||
public void initDevice() {
|
||||
if (initialized) {
|
||||
return; // 已初始化(旋转/重建后 ViewModel 复用),避免重复自检
|
||||
}
|
||||
ui.appendLog("—— 设备开机,本地 TEE 自检(模拟真实设备启动)——");
|
||||
ui.appendLog(" 正在初始化 TEE 密钥对(首次生成 RSA-2048 可能需数秒,请稍候)...");
|
||||
ui.setBusy(true);
|
||||
Disposable d = Single.fromCallable(() -> {
|
||||
// 先探测是否已有持久化密钥,用于区分「首次生成」与「复用」
|
||||
boolean alreadyExists = new DeviceCrypto(app).hasKeyPair();
|
||||
crypto = new DeviceCrypto(app); // 内部 containsAlias 已保证:存在则不重新生成
|
||||
sn = crypto.getDeviceSN();
|
||||
pubKey = crypto.getPublicKeyBase64();
|
||||
return new SelfTestResult(alreadyExists, localSelfTest(crypto, pubKey));
|
||||
})
|
||||
.subscribeOn(Schedulers.computation())
|
||||
// 兜底:Keystore 生成异常慢/异常时避免界面永远停在「处理中」
|
||||
.timeout(30, TimeUnit.SECONDS)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
result -> {
|
||||
initialized = true;
|
||||
ui.appendLog(result.alreadyExists
|
||||
? " TEE 密钥对已存在,复用持久化密钥(未重新生成)"
|
||||
: " TEE 密钥对已新建并持久化");
|
||||
ui.appendLog(result.selfTest);
|
||||
ui.appendLog("设备就绪,正在查询云端注册/绑定状态...");
|
||||
// 本地自检完成后,查询后端是否已注册/绑定,复用已有状态
|
||||
queryDeviceStatus();
|
||||
},
|
||||
e -> {
|
||||
Log.e(TAG, "TEE 初始化失败: " + e.getMessage(), e);
|
||||
ui.appendLog("❌ TEE 初始化失败: " + e.getMessage());
|
||||
ui.appendLog(" 可重新打开页面再次尝试(若为密钥生成超时,请稍候再试)");
|
||||
ui.postToast("TEE 初始化失败: " + e.getMessage());
|
||||
ui.setBusy(false); // 失败/超时也要恢复按钮
|
||||
}
|
||||
);
|
||||
addDisposable(d);
|
||||
}
|
||||
|
||||
/** 本地自检(无需后端) */
|
||||
private String localSelfTest(DeviceCrypto crypto, String pub) throws Exception {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("TEE 密钥对已生成(Android Keystore)\n");
|
||||
sb.append("设备 SN: ").append(sn).append("\n");
|
||||
sb.append("设备公钥(前48): ").append(pub, 0, Math.min(48, pub.length())).append("...\n");
|
||||
|
||||
DeviceCrypto.EncryptedPayload p = crypto.encryptData("本地自检数据".getBytes(StandardCharsets.UTF_8));
|
||||
byte[] dek = Base64.getDecoder().decode(p.dekBase64);
|
||||
String restored = new String(crypto.decryptPhoto(p.ciphertextBase64, p.ivBase64, dek), StandardCharsets.UTF_8);
|
||||
sb.append("信封加密→解密: ").append(restored).append("\n");
|
||||
|
||||
String meta = crypto.getDeviceSN() + "|" + System.currentTimeMillis() + "|local";
|
||||
String sig = crypto.signMetadata(meta);
|
||||
boolean ok = verify(meta, sig, pub);
|
||||
sb.append("元数据签名→验签: ").append(ok ? "通过" : "失败").append("\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 开机自检结果:是否复用已有密钥 + 自检日志 */
|
||||
private static class SelfTestResult {
|
||||
final boolean alreadyExists;
|
||||
final String selfTest;
|
||||
|
||||
SelfTestResult(boolean alreadyExists, String selfTest) {
|
||||
this.alreadyExists = alreadyExists;
|
||||
this.selfTest = selfTest;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 开机查询云端状态(复用注册/绑定) ====================
|
||||
|
||||
/**
|
||||
* 本地自检完成后,查询后端该 SN 是否已注册/已绑定。
|
||||
*
|
||||
* - 已注册且已绑定:直接复用 deviceId / userId,跳到「下载解密」可用步骤,
|
||||
* 用户无需再手动注册 + 绑定(App 重启后免重复操作)。
|
||||
* - 已注册但未绑定:复用 deviceId,跳到「绑定」步骤。
|
||||
* - 未注册:保持原流程,从「注册设备」开始。
|
||||
*
|
||||
* 后端接口 GET /api/device/status?sn=xxx(设备端无登录)。
|
||||
*/
|
||||
private void queryDeviceStatus() {
|
||||
Disposable d = ApiClient.deviceApi().deviceStatus(sn)
|
||||
.map(ApiResponse::getData)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
s -> {
|
||||
if (s.isRegistered()) {
|
||||
deviceId = s.getDeviceId();
|
||||
if (s.isBound() && s.getUserId() != null && !s.getUserId().isBlank()) {
|
||||
// 已注册且已绑定:复用,跳到可用状态
|
||||
userId = s.getUserId();
|
||||
bound = true;
|
||||
ApiClient.saveUserId(userId); // 后续鉴权接口自动携带 X-User-Id
|
||||
ui.appendLog("✅ 云端已有注册/绑定记录,已复用:deviceId=" + deviceId
|
||||
+ ",userId=" + userId + "(免重新注册绑定)");
|
||||
ui.appendLog(" 可直接点击「选择并上传图片」继续,或点击「下载解密照片」");
|
||||
ui.setCurrentStep(4); // 直接进入可用状态(上传/下载/恢复出厂均可用)
|
||||
} else {
|
||||
// 已注册但未绑定:复用 deviceId,跳到绑定步骤
|
||||
ui.appendLog("✅ 云端已有注册记录,已复用:deviceId=" + deviceId + "(尚未绑定用户)");
|
||||
ui.appendLog(" 请输入用户 ID 与手机号后点击「绑定用户」");
|
||||
ui.setCurrentStep(2); // 进入第2步:绑定
|
||||
}
|
||||
} else {
|
||||
// 未注册:保持初始流程
|
||||
ui.appendLog(" 云端未查询到注册记录,需首次注册");
|
||||
ui.appendLog("设备就绪,可开始操作:先点击「注册设备」");
|
||||
ui.setCurrentStep(1); // 进入第1步:注册
|
||||
}
|
||||
refreshStatus();
|
||||
ui.setBusy(false);
|
||||
},
|
||||
e -> {
|
||||
// 查询失败(如后端未启动):设备本地已就绪,回退到手动注册流程
|
||||
Log.w(TAG, "查询云端状态失败,回退到手动注册: " + e.getMessage());
|
||||
ui.appendLog("⚠️ 查询云端状态失败(后端未启动或网络异常),回退到手动流程");
|
||||
ui.appendLog("设备就绪,可开始操作:先点击「注册设备」");
|
||||
ui.setCurrentStep(1);
|
||||
refreshStatus();
|
||||
ui.setBusy(false);
|
||||
}
|
||||
);
|
||||
addDisposable(d);
|
||||
}
|
||||
|
||||
// ==================== ① 设备注册 ====================
|
||||
|
||||
/**
|
||||
* 注册设备(设备端无登录):SN + 公钥 上报后端。
|
||||
* 真实场景:用户购买设备后首次联网激活。
|
||||
*/
|
||||
public void registerDevice() {
|
||||
if (!requireCrypto()) return;
|
||||
ui.appendLog("—— ① 设备注册 ——");
|
||||
runStep("设备注册",
|
||||
ApiClient.deviceApi().registerDevice(new RegisterRequest(sn, pubKey))
|
||||
.map(ApiResponse::getData),
|
||||
(RegisterResponse r) -> {
|
||||
deviceId = r.getDeviceId();
|
||||
ui.appendLog("✅ 注册成功: deviceId=" + deviceId + "(" + r.getMessage() + ")");
|
||||
ui.appendLog(" 下一步:输入用户 ID 与手机号后点击「绑定用户」");
|
||||
ui.setCurrentStep(2); // 进入第2步:绑定
|
||||
refreshStatus();
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ② 绑定用户 ====================
|
||||
|
||||
/**
|
||||
* 用户绑定设备(用户端鉴权):X-User-Id 注入 + SN + 手机号。
|
||||
* 真实场景:用户在 App/网页端登录后扫码或输码绑定自己的设备。
|
||||
*/
|
||||
public void bindUser(String userId, String phone) {
|
||||
if (deviceId == null) {
|
||||
ui.postToast("请先完成「设备注册」");
|
||||
return;
|
||||
}
|
||||
this.userId = userId;
|
||||
this.phone = phone;
|
||||
ApiClient.saveUserId(userId); // 后续接口自动携带 X-User-Id
|
||||
ui.appendLog("—— ② 绑定用户 ——");
|
||||
runStep("绑定用户",
|
||||
ApiClient.deviceApi().bindDevice(new BindRequest(userId, sn, phone))
|
||||
.map(ApiResponse::getData),
|
||||
(MessageResponse r) -> {
|
||||
bound = true;
|
||||
ui.appendLog("✅ 绑定成功: " + userId + " ↔ " + sn
|
||||
+ "(已保存身份,后续接口自动携带 X-User-Id)");
|
||||
ui.appendLog(" 下一步:点击「选择并上传图片」");
|
||||
ui.setCurrentStep(3); // 进入第3步:上传
|
||||
refreshStatus();
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ③ 图片加密上传 ====================
|
||||
|
||||
/**
|
||||
* 从 SAF 返回的 URI 读取图片字节 → 信封加密 + 签名 → 上传。
|
||||
* 读法兼容 SAF(ContentResolver 持久化 URI,无需存储权限)。
|
||||
*/
|
||||
public void uploadSelectedImage(Uri uri) {
|
||||
if (uri == null) {
|
||||
ui.postToast("未选择图片");
|
||||
return;
|
||||
}
|
||||
if (deviceId == null) {
|
||||
ui.postToast("请先完成「设备注册」与「绑定用户」");
|
||||
return;
|
||||
}
|
||||
ui.setSelectedImageUri(uri.toString());
|
||||
ui.appendLog("—— ③ 加密上传所选图片(SAF 选择)——");
|
||||
ui.setBusy(true);
|
||||
|
||||
Disposable imgChain = Single.fromCallable(() -> {
|
||||
byte[] imageBytes = readUriBytes(uri);
|
||||
return new ImageInit(crypto, imageBytes);
|
||||
})
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(r -> encryptAndUploadImage(r.crypto, r.imageBytes)
|
||||
.subscribeOn(Schedulers.computation()))
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnSuccess(resp -> {
|
||||
ui.appendLog("✅ 图片上传完成: photoId=" + resp.getPhotoId()
|
||||
+ "(服务端已用设备公钥验签 + UK 信封加密 DEK)");
|
||||
ui.appendLog(" 下一步:点击「下载解密照片」");
|
||||
ui.setCurrentStep(4); // 进入第4步:下载
|
||||
})
|
||||
.doFinally(() -> ui.setBusy(false))
|
||||
.subscribe(
|
||||
resp -> ui.postToast("图片上传成功: " + resp.getPhotoId()),
|
||||
e -> {
|
||||
ui.appendLog("❌ 图片上传失败: " + e.getMessage());
|
||||
ui.postToast("图片上传失败: " + e.getMessage());
|
||||
}
|
||||
);
|
||||
addDisposable(imgChain);
|
||||
}
|
||||
|
||||
/** 图片上传初始化载体:已读取字节的 DeviceCrypto 与图片数据 */
|
||||
private static class ImageInit {
|
||||
final DeviceCrypto crypto;
|
||||
final byte[] imageBytes;
|
||||
|
||||
ImageInit(DeviceCrypto crypto, byte[] imageBytes) {
|
||||
this.crypto = crypto;
|
||||
this.imageBytes = imageBytes;
|
||||
}
|
||||
}
|
||||
|
||||
private Single<UploadPhotoResponse> encryptAndUploadImage(DeviceCrypto crypto, byte[] imageBytes) {
|
||||
String sn = crypto.getDeviceSN();
|
||||
String photoId = "photo-" + System.currentTimeMillis();
|
||||
DeviceCrypto.EncryptedPayload p = crypto.encryptData(imageBytes);
|
||||
String metadata = sn + "|" + System.currentTimeMillis() + "|" + photoId;
|
||||
String sig = crypto.signMetadata(metadata); // TEE 私钥签名,服务端验签
|
||||
|
||||
lastPhotoId = photoId;
|
||||
lastCiphertextBase64 = p.ciphertextBase64;
|
||||
lastIvBase64 = p.ivBase64;
|
||||
|
||||
ui.appendLog(" 图片加密: photoId=" + photoId
|
||||
+ ",大小=" + imageBytes.length + " 字节,DEK 已生成");
|
||||
|
||||
UploadPhotoRequest req = new UploadPhotoRequest(sn, photoId,
|
||||
p.ciphertextBase64, p.ivBase64, p.dekBase64, sig, metadata);
|
||||
return ApiClient.deviceApi().uploadPhoto(req)
|
||||
.map(ApiResponse::getData); // 解包统一响应,返回业务数据
|
||||
}
|
||||
|
||||
// ==================== ④ 下载解密照片 ====================
|
||||
|
||||
/**
|
||||
* 用户端下载并解密最近上传的照片(返回明文 Base64)。
|
||||
*
|
||||
* 不再依赖内存态 lastPhotoId(App 重启会丢失),而是:
|
||||
* ① 先调 GET /api/user/photos 从后端获取该用户的照片 ID 列表;
|
||||
* ② 取最近一张(列表末尾);
|
||||
* ③ 再调 GET /api/photo/{id}/decrypt 下载并解密。
|
||||
*
|
||||
* 真实场景:用户在其他设备上登录,下载并查看自己设备的加密照片。
|
||||
*/
|
||||
public void downloadLastPhoto() {
|
||||
ui.appendLog("—— ④ 用户下载解密照片 ——");
|
||||
String userId = ApiClient.getUserId();
|
||||
runStep("下载解密",
|
||||
ApiClient.deviceApi().userPhotos(userId)
|
||||
.map(ApiResponse::getData)
|
||||
.flatMap(photos -> {
|
||||
List<String> ids = photos.getPhotos();
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return Single.error(new IllegalStateException("后端无照片记录,请先「选择并上传图片」"));
|
||||
}
|
||||
// 取最近一张(列表按上传时间升序,末尾即最新)
|
||||
String latestId = ids.get(ids.size() - 1);
|
||||
return ApiClient.deviceApi()
|
||||
.downloadAndDecrypt(latestId, userId)
|
||||
.map(ApiResponse::getData);
|
||||
}),
|
||||
(DownloadDecryptResponse d) -> {
|
||||
ui.setDecryptedImageBase64(d.getPlaintextBase64()); // 解密后的图片明文(Base64)→ 预览显示
|
||||
// 注意:不要在日志里打印整段明文(大图会导致 TextView 巨量重绘而 ANR),
|
||||
// 也不在主线程二次 Base64 解码(估算字节数即可)。
|
||||
String b64 = d.getPlaintextBase64();
|
||||
int approxBytes = b64.length() / 4 * 3;
|
||||
ui.appendLog("✅ 下载解密成功(" + d.getPhotoId() + "),明文约 " + approxBytes
|
||||
+ " 字节(已显示到预览区,服务端从后端照片列表取最近一张,信封解密)");
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ⑤ 恢复出厂(销毁 TEE 密钥) ====================
|
||||
|
||||
/**
|
||||
* 模拟恢复出厂:TEE 旧私钥销毁并生成全新密钥对(公钥轮换)。
|
||||
* 真实场景:换机 / 恢复出厂设置后,旧密钥不可恢复,必须走恢复链路找回数据。
|
||||
*/
|
||||
public void resetDevice() {
|
||||
if (!requireCrypto()) return;
|
||||
ui.appendLog("—— ⑤ 恢复出厂(销毁 TEE 密钥)——");
|
||||
runStep("恢复出厂",
|
||||
Single.fromCallable(() -> {
|
||||
crypto.resetKeyPair(); // TEE 旧私钥销毁
|
||||
return crypto.getPublicKeyBase64(); // 新公钥
|
||||
}).subscribeOn(Schedulers.computation()),
|
||||
(String newPub) -> {
|
||||
pubKey = newPub;
|
||||
deviceId = null;
|
||||
bound = false;
|
||||
recoveryToken = null;
|
||||
ui.appendLog("✅ TEE 旧私钥已销毁,新公钥已生成: "
|
||||
+ pubKey.substring(0, Math.min(24, pubKey.length())) + "...");
|
||||
ui.appendLog(" 新公钥无法解开旧数据,必须重新注册 + 短信恢复授权");
|
||||
ui.setCurrentStep(6); // 跳过5(当前步),进入第6步:重新注册
|
||||
refreshStatus();
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ⑥ 重新注册 ====================
|
||||
|
||||
/**
|
||||
* 恢复出厂后重新注册(新公钥),后端会停用旧设备,SN 归属保留。
|
||||
*/
|
||||
public void reRegisterDevice() {
|
||||
if (!requireCrypto()) return;
|
||||
if (deviceId != null) {
|
||||
ui.appendLog("当前已注册 deviceId=" + deviceId + ",仍将用最新公钥重新注册(后端幂等覆盖)");
|
||||
}
|
||||
ui.appendLog("—— ⑥ 重新注册(新公钥)——");
|
||||
runStep("重新注册",
|
||||
ApiClient.deviceApi().registerDevice(new RegisterRequest(sn, pubKey))
|
||||
.map(ApiResponse::getData),
|
||||
(RegisterResponse r) -> {
|
||||
deviceId = r.getDeviceId();
|
||||
ui.appendLog("✅ 重新注册成功: newDeviceId=" + deviceId
|
||||
+ "(旧设备已停用,SN 归属保留)");
|
||||
ui.appendLog(" 下一步:点击「发送短信验证码」");
|
||||
ui.setCurrentStep(7); // 进入第7步:短信
|
||||
refreshStatus();
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ⑦ 发送短信验证码 ====================
|
||||
|
||||
/**
|
||||
* 用户端发送短信验证码到绑定手机号(登录态防轰炸)。
|
||||
* 注意:手机号必须与「绑定用户」时一致,恢复时后端按用户档案手机号校验。
|
||||
*/
|
||||
public void sendSms(String phone) {
|
||||
if (deviceId == null) {
|
||||
ui.postToast("请先完成「重新注册」");
|
||||
return;
|
||||
}
|
||||
this.phone = phone;
|
||||
ApiClient.saveUserId(userId); // 短信接口需要登录态(X-User-Id)
|
||||
ui.appendLog("—— ⑦ 发送短信验证码 → " + phone + " ——");
|
||||
runStep("发送短信",
|
||||
ApiClient.deviceApi().sendSms(new SmsRequest(phone))
|
||||
.map(ApiResponse::getData),
|
||||
(MessageResponse s) -> {
|
||||
ui.appendLog("✅ 短信已发送: " + s.getMessage()
|
||||
+ "(demo 后端固定验证码 000000,见后端控制台)");
|
||||
ui.appendLog(" 下一步:输入验证码后点击「恢复授权」");
|
||||
ui.setCurrentStep(8); // 进入第8步:恢复授权
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ⑧ 恢复授权 ====================
|
||||
|
||||
/**
|
||||
* 恢复授权:短信验证码 + SN 归属双因子,后端校验通过后用设备当前公钥
|
||||
* 加密下发 Recovery Token,设备 TEE 私钥解密得到明文一次性 Token。
|
||||
* 真实场景:新设备上验证短信后,云端信任新设备。
|
||||
*/
|
||||
public void recoverDevice(String smsCode) {
|
||||
if (!requireCrypto()) return;
|
||||
if (deviceId == null) {
|
||||
ui.postToast("请先完成「重新注册」");
|
||||
return;
|
||||
}
|
||||
ui.appendLog("—— ⑧ 恢复授权(短信 + SN 归属双因子)——");
|
||||
runStep("恢复授权",
|
||||
ApiClient.deviceApi()
|
||||
.recoverDevice(new RecoverRequest(userId, sn, smsCode, pubKey))
|
||||
.flatMap(r -> Single.fromCallable(() -> {
|
||||
byte[] tokenBytes = Base64.getDecoder()
|
||||
.decode(r.getData().getEncryptedRecoveryToken());
|
||||
return crypto.decryptRecoveryToken(tokenBytes); // TEE 私钥解密
|
||||
}).subscribeOn(Schedulers.computation())),
|
||||
(String token) -> {
|
||||
recoveryToken = token;
|
||||
bound = true; // 恢复授权通过即视为已重新绑定
|
||||
ui.appendLog("✅ 恢复授权成功,TEE 解密 Recovery Token: " + token
|
||||
+ "(nonce 一次性 + 5 分钟窗口)");
|
||||
ui.appendLog(" 下一步:点击「恢复照片」");
|
||||
ui.setCurrentStep(9); // 进入第9步:恢复照片
|
||||
refreshStatus();
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ⑨ 恢复照片(取回 DEK 并还原) ====================
|
||||
|
||||
/**
|
||||
* 恢复后取回照片 DEK 列表:服务端用设备当前公钥逐条加密 DEK 下发,
|
||||
* 设备 TEE 解密 DEK 后配合密文还原照片。
|
||||
* 真实场景:云端不再信任旧设备,但新设备凭 Token 取回数据。
|
||||
*/
|
||||
public void recoverPhotos() {
|
||||
if (!requireCrypto()) return;
|
||||
if (deviceId == null || recoveryToken == null) {
|
||||
ui.postToast("请先完成「重新注册」与「恢复授权」");
|
||||
return;
|
||||
}
|
||||
ui.appendLog("—— ⑨ 恢复照片(取回 DEK 并还原)——");
|
||||
runStep("恢复照片",
|
||||
ApiClient.deviceApi()
|
||||
.recoverPhotos(new PhotoRecoverRequest(deviceId, recoveryToken, userId))
|
||||
.flatMap(pr -> Single.fromCallable(() -> restoreFromDeks(pr.getData()))
|
||||
.subscribeOn(Schedulers.computation())),
|
||||
(String result) -> ui.appendLog(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 DEK 列表还原最近上传的照片(返回日志文案)。
|
||||
* 密文沿用本地上传缓存(真实场景中密文文件从云端下载,这里聚焦 DEK 还原链路)。
|
||||
*/
|
||||
private String restoreFromDeks(PhotoRecoverResponse pr) {
|
||||
List<EncryptedDek> deks = pr.getDeks();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("✅ 取回 DEK 列表: ").append(pr.getPhotoCount()).append(" 张(Token 验证通过,DEK 用设备公钥逐条加密)\n");
|
||||
if (deks == null || deks.isEmpty()) {
|
||||
return sb.append(" 恢复列表为空,无需还原").toString();
|
||||
}
|
||||
// 匹配本次链路最新上传的 photoId,否则用旧照片 DEK 解新照片密文会 GCM 校验失败。
|
||||
EncryptedDek target = matchDek(deks, lastPhotoId);
|
||||
byte[] dekBytes = crypto.decryptDek(target.getEncryptedDekBase64()); // TEE 解 DEK
|
||||
if (lastCiphertextBase64 == null) {
|
||||
return sb.append(" 本地无密文缓存,跳过内容还原").toString();
|
||||
}
|
||||
byte[] photo = crypto.decryptPhoto(lastCiphertextBase64, lastIvBase64, dekBytes); // AES-GCM 解照片
|
||||
// 只打印明文大小,不打印整段内容(大图会导致日志 TextView 巨量重绘而 ANR)
|
||||
sb.append(" TEE 解密 DEK(").append(target.getPhotoId()).append(") → 还原成功,明文大小 ")
|
||||
.append(photo.length).append(" 字节");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 按 photoId 匹配 DEK,未命中时兜底取第一张 */
|
||||
private EncryptedDek matchDek(List<EncryptedDek> deks, String photoId) {
|
||||
if (photoId != null) {
|
||||
for (EncryptedDek d : deks) {
|
||||
if (photoId.equals(d.getPhotoId())) {
|
||||
return d;
|
||||
}
|
||||
}
|
||||
}
|
||||
return deks.get(0);
|
||||
}
|
||||
|
||||
// ==================== 设备状态摘要 ====================
|
||||
|
||||
private void refreshStatus() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("设备 SN: ").append(sn == null ? "-" : sn).append("\n");
|
||||
sb.append("公钥(前24): ").append(pubKey == null ? "-" : pubKey.substring(0, Math.min(24, pubKey.length()))).append("...\n");
|
||||
sb.append("注册状态: ").append(deviceId == null ? "未注册" : "deviceId=" + deviceId).append("\n");
|
||||
sb.append("绑定用户: ").append(userId).append("(").append(phone).append(")\n");
|
||||
sb.append("恢复出厂: ").append(crypto == null || pubKey == null ? "-" : "已就绪,点击即可销毁密钥").append("\n");
|
||||
sb.append("恢复授权: ").append(recoveryToken == null ? "未授权" : "Token 已就绪").append("\n");
|
||||
sb.append("最近照片: ").append(lastPhotoId == null ? "无" : lastPhotoId);
|
||||
ui.setStatus(sb.toString());
|
||||
|
||||
// 同步驱动按钮可用性的布尔状态
|
||||
ui.setRegistered(deviceId != null);
|
||||
ui.setBound(bound);
|
||||
}
|
||||
|
||||
/** 设备未就绪时给出提示并返回 false,避免抛异常导致界面假死 */
|
||||
private boolean requireCrypto() {
|
||||
if (crypto == null) {
|
||||
ui.postToast("设备尚未初始化,请稍候重试");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== 工具 ====================
|
||||
|
||||
/**
|
||||
* 通过 ContentResolver 读取 URI(兼容 SAF、MediaStore、云端文档)
|
||||
*/
|
||||
private byte[] readUriBytes(Uri uri) throws Exception {
|
||||
try (InputStream is = app.getContentResolver().openInputStream(uri)) {
|
||||
if (is == null) {
|
||||
throw new IllegalStateException("无法打开 URI: " + uri);
|
||||
}
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
byte[] buf = new byte[16 * 1024];
|
||||
int n;
|
||||
while ((n = is.read(buf)) > 0) {
|
||||
bos.write(buf, 0, n);
|
||||
}
|
||||
return bos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean verify(String metadata, String sigBase64, String pubKeyBase64) throws Exception {
|
||||
java.security.PublicKey pub = KeyFactory.getInstance("RSA")
|
||||
.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(pubKeyBase64)));
|
||||
Signature s = Signature.getInstance("SHA256withRSA");
|
||||
s.initVerify(pub);
|
||||
s.update(metadata.getBytes(StandardCharsets.UTF_8));
|
||||
return s.verify(Base64.getDecoder().decode(sigBase64));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.secure.demo.base.mvvm;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.databinding.DataBindingUtil;
|
||||
import androidx.databinding.ViewDataBinding;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.lang.reflect.Type;
|
||||
|
||||
/**
|
||||
* MVVM Activity 基类(DataBinding 版)。
|
||||
*
|
||||
* 泛型参数:
|
||||
* - VB:DataBinding 生成的 Binding 类(如 ActivityMainBinding)
|
||||
* - VM:ViewModel 子类(业务逻辑与 UI 状态容器)
|
||||
*
|
||||
* 职责:
|
||||
* - 通过 {@link DataBindingUtil#setContentView} 自动 inflate 并生成 Binding;
|
||||
* - 设置 {@link #binding#setLifecycleOwner(this)},使布局内 LiveData 绑定随生命周期自动更新;
|
||||
* - 通过 {@link ViewModelProvider} 自动实例化 ViewModel;
|
||||
* - 暴露 {@link #getBinding()} / {@link #getViewModel()} 给子类;
|
||||
* - 在 {@link #onReady(Bundle)} 回调中子类绑定 UI 事件与观察者。
|
||||
*/
|
||||
public abstract class BaseMvvmActivity<VB extends ViewDataBinding, VM extends BaseViewModel>
|
||||
extends AppCompatActivity {
|
||||
|
||||
protected VB binding;
|
||||
protected VM viewModel;
|
||||
|
||||
/** 布局资源 id(对应 DataBinding 生成的 Binding) */
|
||||
@LayoutRes
|
||||
protected abstract int getLayoutId();
|
||||
|
||||
/** ViewModel 的具体 Class(用于 ViewModelProvider 实例化) */
|
||||
protected abstract Class<VM> getViewModelClass();
|
||||
|
||||
/** 可选:自定义 ViewModel 工厂(如带 Application 的工厂),默认无参 */
|
||||
@Nullable
|
||||
protected ViewModelProvider.Factory getViewModelFactory() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 绑定完成后回调:子类在此设置点击监听、LiveData 观察等 */
|
||||
protected abstract void onReady(@Nullable Bundle savedInstanceState);
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// 1. inflate DataBinding 布局,生成强类型 Binding
|
||||
binding = (VB) DataBindingUtil.setContentView(this, getLayoutId());
|
||||
binding.setLifecycleOwner(this);
|
||||
|
||||
// 2. 实例化 ViewModel(生命周期由 Activity 持有,随销毁而 onCleared)
|
||||
ViewModelProvider.Factory factory = getViewModelFactory();
|
||||
ViewModelProvider provider = factory != null
|
||||
? new ViewModelProvider(this, factory)
|
||||
: new ViewModelProvider(this);
|
||||
viewModel = provider.get(getViewModelClass());
|
||||
|
||||
onReady(savedInstanceState);
|
||||
}
|
||||
|
||||
protected VB getBinding() {
|
||||
return binding;
|
||||
}
|
||||
|
||||
protected VM getViewModel() {
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过泛型类型参数推断 VM 的实际 Class(兜底方案)。
|
||||
* 当子类未重写 {@link #getViewModelClass()} 时可用。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
protected Class<VM> inferViewModelClass() {
|
||||
Type superClass = getClass().getGenericSuperclass();
|
||||
while (superClass instanceof Class<?> && superClass != Object.class) {
|
||||
superClass = ((Class<?>) superClass).getGenericSuperclass();
|
||||
}
|
||||
if (superClass instanceof ParameterizedType) {
|
||||
Type[] types = ((ParameterizedType) superClass).getActualTypeArguments();
|
||||
if (types.length >= 2) {
|
||||
return (Class<VM>) types[1];
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("无法推断 ViewModel 类型,请重写 getViewModelClass()");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.secure.demo.base.mvvm;
|
||||
|
||||
import android.app.Application;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.AndroidViewModel;
|
||||
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
/**
|
||||
* ViewModel 基类。
|
||||
*
|
||||
* 生命周期感知的网络请求容器:
|
||||
* - 继承 {@link AndroidViewModel},持有 {@link Application} 上下文(用于 DeviceCrypto、ContentResolver 等系统服务);
|
||||
* - 内部维护 {@link CompositeDisposable},所有 RxJava 订阅统一登记;
|
||||
* - 在 {@link #onCleared()}(ViewModel 生命周期结束,对应 Activity/Fragment 销毁)时
|
||||
* 自动 {@link CompositeDisposable#clear()},避免内存泄漏与后台回调空指针。
|
||||
*
|
||||
* 子类通过 {@link #getApplication()} 获取 Application,发起网络请求时统一调用
|
||||
* {@link #addDisposable(Disposable)}。
|
||||
*/
|
||||
public abstract class BaseViewModel extends AndroidViewModel {
|
||||
|
||||
/** 网络订阅统一管理器,随 ViewModel 销毁而清理 */
|
||||
protected final CompositeDisposable disposables = new CompositeDisposable();
|
||||
|
||||
public BaseViewModel(@NonNull Application application) {
|
||||
super(application);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记一个 RxJava 订阅,使其具备生命周期感知能力。
|
||||
*
|
||||
* @param disposable 由 {@code subscribe()} 返回的 Disposable
|
||||
*/
|
||||
protected void addDisposable(@NonNull Disposable disposable) {
|
||||
disposables.add(disposable);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCleared() {
|
||||
super.onCleared();
|
||||
// ViewModel 销毁(Activity/Fragment 销毁或重建)时统一回收所有网络订阅
|
||||
disposables.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.secure.demo.base.mvvm;
|
||||
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
/**
|
||||
* 通用 UI 状态容器。
|
||||
*
|
||||
* 通过 LiveData 将「日志、进度、结果」三类事件暴露给视图层,
|
||||
* 视图层仅做渲染,不持有业务逻辑。
|
||||
*/
|
||||
public class UiState {
|
||||
|
||||
/** 运行日志(追加式) */
|
||||
private final MutableLiveData<String> logLines = new MutableLiveData<>("");
|
||||
|
||||
/** 是否处于忙(网络/加密进行中),用于禁用按钮、显示进度条。
|
||||
* 初始为 false:按钮默认可用;仅当某步操作进行中由 runStep/initDevice 置 true */
|
||||
private final MutableLiveData<Boolean> busy = new MutableLiveData<>(false);
|
||||
|
||||
/** 当前操作进度步骤(对应 UI 上的 ①-⑨),用于控制按钮可用性 */
|
||||
private final MutableLiveData<Integer> currentStep = new MutableLiveData<>(0);
|
||||
|
||||
/** 设备是否已注册(后端有记录) */
|
||||
private final MutableLiveData<Boolean> registered = new MutableLiveData<>(false);
|
||||
|
||||
/** 设备是否已绑定用户 */
|
||||
private final MutableLiveData<Boolean> bound = new MutableLiveData<>(false);
|
||||
|
||||
public LiveData<Integer> getCurrentStep() {
|
||||
return currentStep;
|
||||
}
|
||||
|
||||
public void setCurrentStep(int step) {
|
||||
currentStep.postValue(step);
|
||||
}
|
||||
|
||||
public LiveData<Boolean> getRegistered() {
|
||||
return registered;
|
||||
}
|
||||
|
||||
public LiveData<Boolean> getBound() {
|
||||
return bound;
|
||||
}
|
||||
|
||||
public void setRegistered(boolean value) {
|
||||
registered.postValue(value);
|
||||
}
|
||||
|
||||
public void setBound(boolean value) {
|
||||
bound.postValue(value);
|
||||
}
|
||||
|
||||
private final MutableLiveData<String> toast = new MutableLiveData<>();
|
||||
|
||||
/** 最近选中的图片 URI(供预览) */
|
||||
private final MutableLiveData<String> selectedImageUri = new MutableLiveData<>();
|
||||
|
||||
/** 下载解密后的图片明文(Base64),供预览显示 */
|
||||
private final MutableLiveData<String> decryptedImageBase64 = new MutableLiveData<>();
|
||||
|
||||
/** 当前设备/用户状态摘要(SN、公钥、注册/绑定/恢复进度) */
|
||||
private final MutableLiveData<String> status = new MutableLiveData<>("");
|
||||
|
||||
public LiveData<String> getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public LiveData<String> getLogLines() {
|
||||
return logLines;
|
||||
}
|
||||
|
||||
public LiveData<Boolean> getBusy() {
|
||||
return busy;
|
||||
}
|
||||
|
||||
public LiveData<String> getToast() {
|
||||
return toast;
|
||||
}
|
||||
|
||||
public LiveData<String> getSelectedImageUri() {
|
||||
return selectedImageUri;
|
||||
}
|
||||
|
||||
public LiveData<String> getDecryptedImageBase64() {
|
||||
return decryptedImageBase64;
|
||||
}
|
||||
|
||||
// ===== 内部写方法(仅 ViewModel 调用) =====
|
||||
|
||||
public synchronized void appendLog(String line) {
|
||||
String cur = logLines.getValue();
|
||||
logLines.postValue((cur == null ? "" : cur) + line + "\n");
|
||||
}
|
||||
|
||||
public void setBusy(boolean value) {
|
||||
busy.postValue(value);
|
||||
}
|
||||
|
||||
public void postToast(String msg) {
|
||||
toast.postValue(msg);
|
||||
}
|
||||
|
||||
public void setSelectedImageUri(String uri) {
|
||||
selectedImageUri.postValue(uri);
|
||||
}
|
||||
|
||||
public void setDecryptedImageBase64(String base64) {
|
||||
decryptedImageBase64.postValue(base64);
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status.postValue(status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.secure.demo.network;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.secure.demo.BuildConfig;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import okhttp3.Interceptor;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.logging.HttpLoggingInterceptor;
|
||||
import retrofit2.Retrofit;
|
||||
import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory;
|
||||
import retrofit2.converter.gson.GsonConverterFactory;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Retrofit 客户端单例。
|
||||
* 技术栈与结构对齐 WebRTCController 的 RetrofitClient:
|
||||
* 后端基地址通过 BuildConfig.API_BASE 注入,支持 OkHttp 拦截器链(AuthInterceptor + 日志)。
|
||||
*/
|
||||
public final class ApiClient {
|
||||
|
||||
/** 请求日志 TAG,便于在 Logcat 中过滤 */
|
||||
private static final String TAG = "SecureDevice-HTTP";
|
||||
|
||||
/** 后端基地址(BuildConfig 注入,模拟器 10.0.2.2 访问宿主机) */
|
||||
// public static final String API_BASE = "http://10.0.2.2:8080";
|
||||
public static final String API_BASE = "http://192.168.100.244:8080";
|
||||
|
||||
private static final long TIMEOUT_SECONDS = 15L;
|
||||
|
||||
private static volatile Retrofit retrofit;
|
||||
private static volatile DeviceApi deviceApi;
|
||||
private static volatile AuthInterceptor authInterceptor;
|
||||
|
||||
/** 界面网络日志监听:每个 HTTP 请求的 method/url/状态码/耗时会回调到这里(可为 null) */
|
||||
private static volatile Consumer<String> httpLogListener;
|
||||
|
||||
private ApiClient() {
|
||||
}
|
||||
|
||||
/** 需在首次发起请求前调用一次(例如 MainActivity.onCreate) */
|
||||
public static synchronized void init(Context context) {
|
||||
if (retrofit != null) {
|
||||
return;
|
||||
}
|
||||
// 1) 完整请求/响应日志:BODY 级别会打印 method、url、headers 及请求/响应体
|
||||
HttpLoggingInterceptor logging = new HttpLoggingInterceptor(
|
||||
message -> Log.d(TAG, message));
|
||||
logging.setLevel(BuildConfig.DEBUG
|
||||
? HttpLoggingInterceptor.Level.BODY
|
||||
: HttpLoggingInterceptor.Level.NONE);
|
||||
|
||||
// 2) 请求摘要日志:一行打印 method、url、状态码与耗时,便于快速定位慢请求。
|
||||
// 同时转发给界面(httpLogListener),让用户在 App 日志区也能看到每次网络请求。
|
||||
Interceptor requestSummary = chain -> {
|
||||
Request request = chain.request();
|
||||
long startNanos = System.nanoTime();
|
||||
try {
|
||||
Response response = chain.proceed(request);
|
||||
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);
|
||||
String msg = String.format("%s %s -> %d (%dms)",
|
||||
request.method(), request.url(), response.code(), elapsedMs);
|
||||
Log.d(TAG, msg);
|
||||
notifyHttpLog(msg);
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);
|
||||
String msg = String.format("%s %s -> 请求失败 (%dms): %s",
|
||||
request.method(), request.url(), elapsedMs, e.getMessage());
|
||||
Log.w(TAG, msg);
|
||||
notifyHttpLog(msg);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
authInterceptor = new AuthInterceptor(context);
|
||||
OkHttpClient client = new OkHttpClient.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(logging)
|
||||
.addInterceptor(requestSummary)
|
||||
.connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
retrofit = new Retrofit.Builder()
|
||||
.baseUrl(normalizeBaseUrl(API_BASE))
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 获取设备安全 API 服务实例 */
|
||||
public static DeviceApi deviceApi() {
|
||||
if (retrofit == null) {
|
||||
throw new IllegalStateException("ApiClient.init(Context) 必须在使用前调用");
|
||||
}
|
||||
if (deviceApi == null) {
|
||||
deviceApi = retrofit.create(DeviceApi.class);
|
||||
}
|
||||
return deviceApi;
|
||||
}
|
||||
|
||||
/** 保存当前用户 ID(绑定成功后调用),AuthInterceptor 将自动注入 X-User-Id */
|
||||
public static void saveUserId(String userId) {
|
||||
if (authInterceptor == null) {
|
||||
throw new IllegalStateException("ApiClient.init(Context) 必须先调用");
|
||||
}
|
||||
authInterceptor.saveUserId(userId);
|
||||
}
|
||||
|
||||
/** 读取当前用户 ID(照片下载/恢复等鉴权接口使用) */
|
||||
public static String getUserId() {
|
||||
return authInterceptor != null ? authInterceptor.getUserId() : null;
|
||||
}
|
||||
|
||||
/** 注册界面网络日志监听(App 日志区实时显示每次请求),传 null 注销 */
|
||||
public static void setHttpLogListener(Consumer<String> listener) {
|
||||
httpLogListener = listener;
|
||||
}
|
||||
|
||||
private static void notifyHttpLog(String msg) {
|
||||
Consumer<String> listener = httpLogListener;
|
||||
if (listener != null) {
|
||||
try {
|
||||
listener.accept(msg);
|
||||
} catch (Exception ignored) {
|
||||
// 界面回调异常不影响网络请求本身
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeBaseUrl(String base) {
|
||||
return base.endsWith("/") ? base : base + "/";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.secure.demo.network;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import okhttp3.Interceptor;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* 认证拦截器:从本地偏好读取用户 ID,自动注入 X-User-Id 请求头。
|
||||
* 对齐 WebRTCController 的 AuthInterceptor 模式(Token 注入);
|
||||
* 本 demo 后端以 X-User-Id 做照片归属鉴权。
|
||||
*/
|
||||
public class AuthInterceptor implements Interceptor {
|
||||
|
||||
public static final String PREFS_NAME = "secure_device_prefs";
|
||||
public static final String KEY_USER_ID = "user_id";
|
||||
|
||||
private final Context appContext;
|
||||
|
||||
public AuthInterceptor(Context context) {
|
||||
this.appContext = context.getApplicationContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response intercept(Chain chain) throws IOException {
|
||||
Request.Builder builder = chain.request().newBuilder();
|
||||
String userId = getUserId();
|
||||
if (userId != null && !userId.isEmpty()) {
|
||||
builder.header("X-User-Id", userId);
|
||||
}
|
||||
return chain.proceed(builder.build());
|
||||
}
|
||||
|
||||
public void saveUserId(String userId) {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putString(KEY_USER_ID, userId).apply();
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
SharedPreferences sp = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
return sp.getString(KEY_USER_ID, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.secure.demo.network;
|
||||
|
||||
import com.secure.demo.network.model.ApiResponse;
|
||||
import com.secure.demo.network.model.BindRequest;
|
||||
import com.secure.demo.network.model.DownloadDecryptResponse;
|
||||
import com.secure.demo.network.model.DeviceStatusResponse;
|
||||
import com.secure.demo.network.model.HealthResponse;
|
||||
import com.secure.demo.network.model.MessageResponse;
|
||||
import com.secure.demo.network.model.PhotoRecoverRequest;
|
||||
import com.secure.demo.network.model.PhotoRecoverResponse;
|
||||
import com.secure.demo.network.model.RecoverRequest;
|
||||
import com.secure.demo.network.model.RecoverResponse;
|
||||
import com.secure.demo.network.model.RegisterRequest;
|
||||
import com.secure.demo.network.model.RegisterResponse;
|
||||
import com.secure.demo.network.model.SmsRequest;
|
||||
import com.secure.demo.network.model.UploadPhotoRequest;
|
||||
import com.secure.demo.network.model.UploadPhotoResponse;
|
||||
import com.secure.demo.network.model.UserPhotosResponse;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import retrofit2.http.Body;
|
||||
import retrofit2.http.GET;
|
||||
import retrofit2.http.Header;
|
||||
import retrofit2.http.POST;
|
||||
import retrofit2.http.Path;
|
||||
import retrofit2.http.Query;
|
||||
|
||||
/**
|
||||
* 设备安全后端 API 接口定义。
|
||||
* 对应 springboot-server 的 DeviceController 全部接口。
|
||||
*
|
||||
* 前后端分离后所有接口统一返回 {@link ApiResponse}{code, message, data},
|
||||
* 业务数据通过 {@link ApiResponse#getData()} 获取。
|
||||
*/
|
||||
public interface DeviceApi {
|
||||
|
||||
/** 0. 查询设备注册/绑定状态(设备端无登录,App 启动时复用已有状态) */
|
||||
@GET("api/device/status")
|
||||
Single<ApiResponse<DeviceStatusResponse>> deviceStatus(@Query("sn") String sn);
|
||||
|
||||
/** 1. 设备注册(SN + 公钥,设备端无登录) */
|
||||
@POST("api/device/register")
|
||||
Single<ApiResponse<RegisterResponse>> registerDevice(@Body RegisterRequest body);
|
||||
|
||||
/** 2. 用户绑定设备(用户端,Bearer Token / X-User-Id 鉴权) */
|
||||
@POST("api/device/bind")
|
||||
Single<ApiResponse<MessageResponse>> bindDevice(@Body BindRequest body);
|
||||
|
||||
/** 3. 发送短信验证码(用户端,登录态防轰炸) */
|
||||
@POST("api/device/sms/send")
|
||||
Single<ApiResponse<MessageResponse>> sendSms(@Body SmsRequest body);
|
||||
|
||||
/** 4. 上传加密照片(信封加密,DEK 明文经 HTTPS 传输,设备端无登录) */
|
||||
@POST("api/photo/upload")
|
||||
Single<ApiResponse<UploadPhotoResponse>> uploadPhoto(@Body UploadPhotoRequest body);
|
||||
|
||||
/** 5. 恢复设备(短信验证 + 新公钥,换机/恢复出厂后调用,用户端) */
|
||||
@POST("api/device/recover")
|
||||
Single<ApiResponse<RecoverResponse>> recoverDevice(@Body RecoverRequest body);
|
||||
|
||||
/** 6. 恢复后获取照片 DEK 列表(服务端用设备公钥逐一加密 DEK 下发,设备端无登录) */
|
||||
@POST("api/photo/recover")
|
||||
Single<ApiResponse<PhotoRecoverResponse>> recoverPhotos(@Body PhotoRecoverRequest body);
|
||||
|
||||
/** 7. 用户下载并解密照片(Bearer Token / X-User-Id 鉴权,返回明文照片 Base64 的 JSON) */
|
||||
@GET("api/photo/{photoId}/decrypt")
|
||||
Single<ApiResponse<DownloadDecryptResponse>> downloadAndDecrypt(@Path("photoId") String photoId,
|
||||
@Header("X-User-Id") String userId);
|
||||
|
||||
/** 7b. 用户照片列表(用户端,X-User-Id 鉴权,返回 photoId 列表) */
|
||||
@GET("api/user/photos")
|
||||
Single<ApiResponse<UserPhotosResponse>> userPhotos(@Header("X-User-Id") String userId);
|
||||
|
||||
/** 8. 健康检查(公开) */
|
||||
@GET("api/health")
|
||||
Single<ApiResponse<HealthResponse>> health();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
/**
|
||||
* 后端统一响应包装(与 springboot-server 的 ApiResponse 对应)。
|
||||
*
|
||||
* 前后端分离后,所有接口返回:
|
||||
* <pre>
|
||||
* { "code": 0, "message": "ok", "data": { ... } }
|
||||
* </pre>
|
||||
* code == 0 表示成功,业务数据在 data 中。
|
||||
*/
|
||||
public class ApiResponse<T> {
|
||||
|
||||
public static final int CODE_OK = 0;
|
||||
|
||||
private int code;
|
||||
private String message;
|
||||
private T data;
|
||||
|
||||
public ApiResponse() {
|
||||
}
|
||||
|
||||
public ApiResponse(int code, String message, T data) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/** 是否业务成功 */
|
||||
public boolean isOk() {
|
||||
return code == CODE_OK;
|
||||
}
|
||||
|
||||
// ===== Getters & Setters =====
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public T getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public void setData(T data) {
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 用户绑定设备请求:{ userId, sn, phone }(phone 可选,未传则由服务端使用默认值) */
|
||||
public class BindRequest {
|
||||
|
||||
@SerializedName("userId")
|
||||
private final String userId;
|
||||
|
||||
@SerializedName("sn")
|
||||
private final String sn;
|
||||
|
||||
@SerializedName("phone")
|
||||
private final String phone;
|
||||
|
||||
public BindRequest(String userId, String sn, String phone) {
|
||||
this.userId = userId;
|
||||
this.sn = sn;
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getSn() {
|
||||
return sn;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* 设备注册/绑定状态查询响应。
|
||||
*
|
||||
* 对应后端 GET /api/device/status?sn=xxx 的 data 字段:
|
||||
* { registered, bound, active, deviceId, userId, publicKeyBase64 }
|
||||
*
|
||||
* 用途:App 重启后先查询该 SN 是否已注册/已绑定,避免重复注册与绑定。
|
||||
*/
|
||||
public class DeviceStatusResponse {
|
||||
|
||||
@SerializedName("registered")
|
||||
private boolean registered;
|
||||
|
||||
@SerializedName("bound")
|
||||
private boolean bound;
|
||||
|
||||
@SerializedName("active")
|
||||
private boolean active;
|
||||
|
||||
@SerializedName("deviceId")
|
||||
private String deviceId;
|
||||
|
||||
@SerializedName("userId")
|
||||
private String userId;
|
||||
|
||||
@SerializedName("publicKeyBase64")
|
||||
private String publicKeyBase64;
|
||||
|
||||
public boolean isRegistered() {
|
||||
return registered;
|
||||
}
|
||||
|
||||
public boolean isBound() {
|
||||
return bound;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public String getDeviceId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getPublicKeyBase64() {
|
||||
return publicKeyBase64;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 下载并解密照片响应:{ photoId, plaintextBase64 }(明文照片 Base64) */
|
||||
public class DownloadDecryptResponse {
|
||||
|
||||
@SerializedName("photoId")
|
||||
private String photoId;
|
||||
|
||||
@SerializedName("plaintextBase64")
|
||||
private String plaintextBase64;
|
||||
|
||||
public String getPhotoId() {
|
||||
return photoId;
|
||||
}
|
||||
|
||||
public String getPlaintextBase64() {
|
||||
return plaintextBase64;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** DEK 条目:{ photoId, encryptedDekBase64 } */
|
||||
public class EncryptedDek {
|
||||
|
||||
@SerializedName("photoId")
|
||||
private String photoId;
|
||||
|
||||
@SerializedName("encryptedDekBase64")
|
||||
private String encryptedDekBase64;
|
||||
|
||||
public String getPhotoId() {
|
||||
return photoId;
|
||||
}
|
||||
|
||||
public String getEncryptedDekBase64() {
|
||||
return encryptedDekBase64;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 健康检查响应:{ status, service } */
|
||||
public class HealthResponse {
|
||||
|
||||
@SerializedName("status")
|
||||
private String status;
|
||||
|
||||
@SerializedName("service")
|
||||
private String service;
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String getService() {
|
||||
return service;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 通用消息响应:{ message }(绑定、发短信等接口返回) */
|
||||
public class MessageResponse {
|
||||
|
||||
@SerializedName("message")
|
||||
private String message;
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 恢复后获取照片 DEK 列表请求:{ deviceId, recoveryToken, userId } */
|
||||
public class PhotoRecoverRequest {
|
||||
|
||||
@SerializedName("deviceId")
|
||||
private final String deviceId;
|
||||
|
||||
@SerializedName("recoveryToken")
|
||||
private final String recoveryToken;
|
||||
|
||||
@SerializedName("userId")
|
||||
private final String userId;
|
||||
|
||||
public PhotoRecoverRequest(String deviceId, String recoveryToken, String userId) {
|
||||
this.deviceId = deviceId;
|
||||
this.recoveryToken = recoveryToken;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getDeviceId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public String getRecoveryToken() {
|
||||
return recoveryToken;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 恢复后获取照片 DEK 列表响应。
|
||||
* { deviceId, photoCount, deks: [{ photoId, encryptedDekBase64 }] }
|
||||
* 每个 encryptedDekBase64 均由服务端用设备当前公钥加密,仅设备私钥可解。
|
||||
*/
|
||||
public class PhotoRecoverResponse {
|
||||
|
||||
@SerializedName("deviceId")
|
||||
private String deviceId;
|
||||
|
||||
@SerializedName("photoCount")
|
||||
private int photoCount;
|
||||
|
||||
@SerializedName("deks")
|
||||
private List<EncryptedDek> deks;
|
||||
|
||||
public String getDeviceId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public int getPhotoCount() {
|
||||
return photoCount;
|
||||
}
|
||||
|
||||
public List<EncryptedDek> getDeks() {
|
||||
return deks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* 恢复设备请求(恢复出厂/换机后重新授权)。
|
||||
* { userId, sn, smsCode, newPublicKeyBase64 }
|
||||
*/
|
||||
public class RecoverRequest {
|
||||
|
||||
@SerializedName("userId")
|
||||
private final String userId;
|
||||
|
||||
@SerializedName("sn")
|
||||
private final String sn;
|
||||
|
||||
@SerializedName("smsCode")
|
||||
private final String smsCode;
|
||||
|
||||
@SerializedName("newPublicKeyBase64")
|
||||
private final String newPublicKeyBase64;
|
||||
|
||||
public RecoverRequest(String userId, String sn, String smsCode, String newPublicKeyBase64) {
|
||||
this.userId = userId;
|
||||
this.sn = sn;
|
||||
this.smsCode = smsCode;
|
||||
this.newPublicKeyBase64 = newPublicKeyBase64;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getSn() {
|
||||
return sn;
|
||||
}
|
||||
|
||||
public String getSmsCode() {
|
||||
return smsCode;
|
||||
}
|
||||
|
||||
public String getNewPublicKeyBase64() {
|
||||
return newPublicKeyBase64;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* 恢复设备响应。
|
||||
* { deviceId, encryptedRecoveryToken, nonce, message }
|
||||
* encryptedRecoveryToken 由服务端用新设备公钥加密,仅新设备私钥可解。
|
||||
*/
|
||||
public class RecoverResponse {
|
||||
|
||||
@SerializedName("deviceId")
|
||||
private String deviceId;
|
||||
|
||||
@SerializedName("encryptedRecoveryToken")
|
||||
private String encryptedRecoveryToken;
|
||||
|
||||
@SerializedName("nonce")
|
||||
private String nonce;
|
||||
|
||||
@SerializedName("message")
|
||||
private String message;
|
||||
|
||||
public String getDeviceId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public String getEncryptedRecoveryToken() {
|
||||
return encryptedRecoveryToken;
|
||||
}
|
||||
|
||||
public String getNonce() {
|
||||
return nonce;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 设备注册请求:{ sn, publicKeyBase64 } */
|
||||
public class RegisterRequest {
|
||||
|
||||
@SerializedName("sn")
|
||||
private final String sn;
|
||||
|
||||
@SerializedName("publicKeyBase64")
|
||||
private final String publicKeyBase64;
|
||||
|
||||
public RegisterRequest(String sn, String publicKeyBase64) {
|
||||
this.sn = sn;
|
||||
this.publicKeyBase64 = publicKeyBase64;
|
||||
}
|
||||
|
||||
public String getSn() {
|
||||
return sn;
|
||||
}
|
||||
|
||||
public String getPublicKeyBase64() {
|
||||
return publicKeyBase64;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 设备注册响应:{ deviceId, sn, message } */
|
||||
public class RegisterResponse {
|
||||
|
||||
@SerializedName("deviceId")
|
||||
private String deviceId;
|
||||
|
||||
@SerializedName("sn")
|
||||
private String sn;
|
||||
|
||||
@SerializedName("message")
|
||||
private String message;
|
||||
|
||||
public String getDeviceId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public String getSn() {
|
||||
return sn;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 发送短信验证码请求:{ phone } */
|
||||
public class SmsRequest {
|
||||
|
||||
@SerializedName("phone")
|
||||
private final String phone;
|
||||
|
||||
public SmsRequest(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* 上传加密照片请求(信封加密)。
|
||||
* { sn, photoId, ciphertextBase64, ivBase64, dekBase64, metadataSignature, metadata }
|
||||
* 服务端用用户密钥 UK 加密 DEK 后存储,永不保存明文 DEK。
|
||||
*/
|
||||
public class UploadPhotoRequest {
|
||||
|
||||
@SerializedName("sn")
|
||||
private final String sn;
|
||||
|
||||
@SerializedName("photoId")
|
||||
private final String photoId;
|
||||
|
||||
@SerializedName("ciphertextBase64")
|
||||
private final String ciphertextBase64;
|
||||
|
||||
@SerializedName("ivBase64")
|
||||
private final String ivBase64;
|
||||
|
||||
@SerializedName("dekBase64")
|
||||
private final String dekBase64;
|
||||
|
||||
@SerializedName("metadataSignature")
|
||||
private final String metadataSignature;
|
||||
|
||||
@SerializedName("metadata")
|
||||
private final String metadata;
|
||||
|
||||
public UploadPhotoRequest(String sn, String photoId, String ciphertextBase64,
|
||||
String ivBase64, String dekBase64,
|
||||
String metadataSignature, String metadata) {
|
||||
this.sn = sn;
|
||||
this.photoId = photoId;
|
||||
this.ciphertextBase64 = ciphertextBase64;
|
||||
this.ivBase64 = ivBase64;
|
||||
this.dekBase64 = dekBase64;
|
||||
this.metadataSignature = metadataSignature;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
public String getSn() {
|
||||
return sn;
|
||||
}
|
||||
|
||||
public String getPhotoId() {
|
||||
return photoId;
|
||||
}
|
||||
|
||||
public String getCiphertextBase64() {
|
||||
return ciphertextBase64;
|
||||
}
|
||||
|
||||
public String getIvBase64() {
|
||||
return ivBase64;
|
||||
}
|
||||
|
||||
public String getDekBase64() {
|
||||
return dekBase64;
|
||||
}
|
||||
|
||||
public String getMetadataSignature() {
|
||||
return metadataSignature;
|
||||
}
|
||||
|
||||
public String getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 上传加密照片响应:{ photoId, message } */
|
||||
public class UploadPhotoResponse {
|
||||
|
||||
@SerializedName("photoId")
|
||||
private String photoId;
|
||||
|
||||
@SerializedName("message")
|
||||
private String message;
|
||||
|
||||
public String getPhotoId() {
|
||||
return photoId;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户照片列表响应。
|
||||
*
|
||||
* 对应后端 GET /api/user/photos 的 data 字段:
|
||||
* { "photos": ["photo-001", "photo-002", ...] }
|
||||
*/
|
||||
public class UserPhotosResponse {
|
||||
|
||||
@SerializedName("photos")
|
||||
private List<String> photos;
|
||||
|
||||
public List<String> getPhotos() {
|
||||
return photos;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.secure.demo.util;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文件读写权限适配工具。
|
||||
*
|
||||
* 适配策略:
|
||||
* - Android 9 (API 28) 及以下:需 {@link Manifest.permission#READ_EXTERNAL_STORAGE} / WRITE_EXTERNAL_STORAGE
|
||||
* - Android 10 (API 29) 及以上:分区存储(Scoped Storage),普通应用无需存储权限即可访问
|
||||
* 自身 {@code MediaStore} / 专属目录;访问其它位置请走 SAF(见 {@link SafPicker})
|
||||
* - Android 13 (API 33) 及以上:细粒度媒体权限 READ_MEDIA_IMAGES 等
|
||||
*
|
||||
* 注意:本 demo 选择图片优先使用 SAF(ACTION_OPEN_DOCUMENT),无需任何存储权限,
|
||||
* 此工具用于「明确需要直接访问文件」的场景(如兼容旧设备)。
|
||||
*/
|
||||
public final class PermissionHelper {
|
||||
|
||||
private PermissionHelper() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前设备缺失的、选择/读取图片所需权限列表。
|
||||
* Android 10+ 通常返回空列表(不需要权限)。
|
||||
*/
|
||||
@NonNull
|
||||
public static List<String> getMissingImagePermissions(@NonNull Context context) {
|
||||
List<String> missing = new ArrayList<>();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// Android 13+:细粒度图片权限
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_IMAGES)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
missing.add(Manifest.permission.READ_MEDIA_IMAGES);
|
||||
}
|
||||
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
// Android 9-:旧式存储权限
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
missing.add(Manifest.permission.READ_EXTERNAL_STORAGE);
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
missing.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
|
||||
}
|
||||
}
|
||||
// Android 10/11/12 (Q/R/S):分区存储,无需权限
|
||||
return missing;
|
||||
}
|
||||
|
||||
/** 是否有读取/选择图片所需的权限(SAF 路径下恒为 true) */
|
||||
public static boolean hasImagePermission(@NonNull Context context) {
|
||||
return getMissingImagePermissions(context).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要向用户申请权限(Android 10+ 不需要,返回 false)。
|
||||
*/
|
||||
public static boolean needsRequestPermission(@NonNull Context context) {
|
||||
return !getMissingImagePermissions(context).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.secure.demo.util;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.provider.DocumentsContract;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
/**
|
||||
* SAF(Storage Access Framework)选择器封装。
|
||||
*
|
||||
* 通过系统 {@link Intent#ACTION_OPEN_DOCUMENT} 调起系统文档选择器,
|
||||
* 用户选择后获得一个持久化 URI({@link DocumentsContract#EXTRA_PERSISTABLE_URI_PERMISSIONS}),
|
||||
* 应用可长期、免权限地通过 ContentResolver 读取该文件。
|
||||
*
|
||||
* 兼容点:
|
||||
* - 不需要 READ_EXTERNAL_STORAGE 等危险权限;
|
||||
* - 适配 Android 10+ 分区存储,也可访问外部 SD / 云端文档(Drive 等);
|
||||
* - 通过 {@link ActivityResultLauncher}(新 API)回调,天然具备生命周期安全。
|
||||
*/
|
||||
public final class SafPicker {
|
||||
|
||||
/** 选择回调:返回用户所选文件的 URI(已取持久化读权限) */
|
||||
public interface OnPicked {
|
||||
void onPicked(@NonNull Uri uri);
|
||||
}
|
||||
|
||||
private final ActivityResultLauncher<Intent> launcher;
|
||||
private OnPicked callback;
|
||||
|
||||
public SafPicker(@NonNull AppCompatActivity activity, @NonNull OnPicked callback) {
|
||||
this.callback = callback;
|
||||
this.launcher = activity.registerForActivityResult(
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {
|
||||
if (result.getResultCode() == Activity.RESULT_OK
|
||||
&& result.getData() != null) {
|
||||
Uri uri = result.getData().getData();
|
||||
if (uri != null) {
|
||||
// 持久化读权限,使后续启动无需再次选择(部分 Provider 不支持,忽略异常)
|
||||
try {
|
||||
activity.getContentResolver().takePersistableUriPermission(
|
||||
uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
} catch (Exception e) {
|
||||
android.util.Log.w("SafPicker", "持久化授权失败(本次会话仍可读): " + e.getMessage());
|
||||
}
|
||||
this.callback.onPicked(uri);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 打开系统图片选择器(仅图片类型) */
|
||||
public void openImagePicker() {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("image/*");
|
||||
// 仅展示图片类型,兼容部分文件管理器
|
||||
intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"image/*"});
|
||||
// 请求持久化读权限
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
|
||||
launcher.launch(intent);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,22 @@ public class DeviceCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 密钥状态 ====================
|
||||
|
||||
/**
|
||||
* 判断 TEE 密钥对是否已存在(是否首次生成)。
|
||||
*
|
||||
* 密钥对一旦生成即持久化于 AndroidKeyStore,App 重启、进程销毁都不会丢失;
|
||||
* 仅在「恢复出厂 resetKeyPair()」或系统清除 Keystore 时才会真正重新生成。
|
||||
*/
|
||||
public boolean hasKeyPair() {
|
||||
try {
|
||||
return keyStore.containsAlias(KEY_ALIAS);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 公钥导出 ====================
|
||||
|
||||
/**
|
||||
@@ -219,6 +235,26 @@ public class DeviceCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 密钥生命周期(恢复出厂) ====================
|
||||
|
||||
/**
|
||||
* 模拟恢复出厂:销毁 TEE 中的密钥对并重新生成。
|
||||
*
|
||||
* 真实场景中,恢复出厂会擦除整个 TEE 密钥存储区(出厂重置):
|
||||
* - 旧私钥彻底销毁(服务端随即停用旧设备)
|
||||
* - 生成全新密钥对,仅新私钥可解密后续下发的 Recovery Token / DEK
|
||||
*/
|
||||
public void resetKeyPair() {
|
||||
try {
|
||||
if (keyStore.containsAlias(KEY_ALIAS)) {
|
||||
keyStore.deleteEntry(KEY_ALIAS);
|
||||
}
|
||||
generateTeeKeyPair();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Reset key pair failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 恢复流程 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,27 +1,254 @@
|
||||
<?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="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="SecureDevice Demo"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<ScrollView
|
||||
<data>
|
||||
<variable
|
||||
name="click"
|
||||
type="com.secure.demo.activity.main.MainActivity.ViewClick" />
|
||||
</data>
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:background="#F5F6FA">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- 标题 -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="安全设备演示 · 模拟真实操作"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1A1A2E" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="按真实使用流程逐步手动操作(注册 → 绑定 → 上传 → 下载 → 恢复出厂 → 重新注册 → 短信 → 恢复)"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#888888" />
|
||||
|
||||
<!-- 设备状态摘要 -->
|
||||
<TextView
|
||||
android:id="@+id/status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="#FFFFFF"
|
||||
android:padding="12dp"
|
||||
android:text="设备未初始化..."
|
||||
android:textSize="13sp"
|
||||
android:typeface="monospace"
|
||||
android:textColor="#333333"
|
||||
android:elevation="1dp" />
|
||||
|
||||
<!-- 用户 ID -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="用户 ID(绑定/恢复身份)"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#555555" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/user_id"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="例如 user-001"
|
||||
android:text="user-001"
|
||||
android:inputType="text"
|
||||
android:background="@android:drawable/editbox_background" />
|
||||
|
||||
<!-- 手机号 -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="手机号(绑定与短信必须一致)"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#555555" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/phone"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="例如 13800138000"
|
||||
android:text="13800138000"
|
||||
android:inputType="phone"
|
||||
android:background="@android:drawable/editbox_background" />
|
||||
|
||||
<!-- ① 注册设备 | ② 绑定用户 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_register"
|
||||
android:layout_width="0dp"
|
||||
android:onClick="@{click::registerDevice}"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="① 注册设备"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_bind"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:onClick="@{click::bindUser}"
|
||||
android:text="② 绑定用户"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ③ 上传图片 | ④ 下载解密 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_upload_image"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:onClick="@{click::uploadImage}"
|
||||
android:text="③ 选择并上传图片"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_download"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:onClick="@{click::downloadPhoto}"
|
||||
android:text="④ 下载解密照片"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ⑤ 恢复出厂 | ⑥ 重新注册 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_reset"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:onClick="@{click::resetDevice}"
|
||||
android:text="⑤ 恢复出厂"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_reregister"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:onClick="@{click::reRegisterDevice}"
|
||||
android:text="⑥ 重新注册"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ⑦ 发送短信 -->
|
||||
<Button
|
||||
android:id="@+id/btn_sms"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:onClick="@{click::sendSms}"
|
||||
android:text="⑦ 发送短信验证码"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<!-- 短信验证码 -->
|
||||
<EditText
|
||||
android:id="@+id/sms_code"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:hint="短信验证码(demo 固定 000000)"
|
||||
android:text="000000"
|
||||
android:inputType="number"
|
||||
android:background="@android:drawable/editbox_background" />
|
||||
|
||||
<!-- ⑧ 恢复授权 | ⑨ 恢复照片 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_recover"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:onClick="@{click::recoverDevice}"
|
||||
android:text="⑧ 恢复授权"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_recover_photos"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:onClick="@{click::recoverPhotos}"
|
||||
android:text="⑨ 恢复照片"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<ImageView
|
||||
android:id="@+id/img_preview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="180dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:scaleType="centerInside"
|
||||
android:background="#EEEEEE"
|
||||
android:contentDescription="选中图片预览" />
|
||||
|
||||
<!-- 日志区 -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="运行日志"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#555555" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/log"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_height="220dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="#1E1E2E"
|
||||
android:padding="10dp"
|
||||
android:textColor="#D0E8D0"
|
||||
android:textSize="12sp"
|
||||
android:typeface="monospace" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
android:typeface="monospace"
|
||||
android:scrollbars="vertical"
|
||||
android:fadeScrollbars="false"
|
||||
android:focusable="true"
|
||||
android:clickable="true" />
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</layout>
|
||||
|
||||
Reference in New Issue
Block a user