feat: 重构为前后端分离架构并完善设备端演示

- 新增 Web 用户端(登录体系 + 统一 REST API 调用)
- 后端增加用户认证、统一 ApiResponse、CORS 支持
- Android 设备端迁移至 MVVM + DataBinding + Retrofit 网络层
- 完善 README 架构说明与密码学原理文档
- 新增 .gitignore 与持久化数据表说明
This commit is contained in:
TongTongStudio
2026-08-21 04:34:58 +08:00
parent eda03d241d
commit 93499fa189
63 changed files with 4398 additions and 455 deletions

View File

@@ -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'
// 网络层(技术栈对齐 WebRTCControllerRetrofit + 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'
}

View File

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

View File

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

View File

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

View File

@@ -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;
/** 设备 SNTEE 派生,固定不变) */
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 读取图片字节 → 信封加密 + 签名 → 上传。
* 读法兼容 SAFContentResolver 持久化 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
*
* 不再依赖内存态 lastPhotoIdApp 重启会丢失),而是:
* ① 先调 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));
}
}

View File

@@ -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 版)。
*
* 泛型参数:
* - VBDataBinding 生成的 Binding 类(如 ActivityMainBinding
* - VMViewModel 子类(业务逻辑与 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()");
}
}

View File

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

View File

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

View File

@@ -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 + "/";
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 选择图片优先使用 SAFACTION_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();
}
}

View File

@@ -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;
/**
* SAFStorage 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);
}
}

View File

@@ -125,6 +125,22 @@ public class DeviceCrypto {
}
}
// ==================== 密钥状态 ====================
/**
* 判断 TEE 密钥对是否已存在(是否首次生成)。
*
* 密钥对一旦生成即持久化于 AndroidKeyStoreApp 重启、进程销毁都不会丢失;
* 仅在「恢复出厂 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);
}
}
// ==================== 恢复流程 ====================
/**

View File

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