diff --git a/WebRTCControlled/app/build.gradle b/WebRTCControlled/app/build.gradle index a34d083..10d6154 100644 --- a/WebRTCControlled/app/build.gradle +++ b/WebRTCControlled/app/build.gradle @@ -1,6 +1,8 @@ plugins { id 'com.android.application' id 'com.google.protobuf' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.kapt' } def releaseTime() { @@ -23,7 +25,7 @@ android { versionName "1.0" // 服务端地址(信令 wss 与 HTTP api 同源)。部署时通过 flavor / CI 注入真实值。 - buildConfigField "String", "API_BASE", "\"https://www.ttstd.com\"" + buildConfigField "String", "API_BASE", "\"http://192.168.5.224:8080\"" // 出厂预置共享密钥(用于 provision 签名)。正式发布必须替换并通过安全方式注入。 buildConfigField "String", "DEVICE_PROVISION_SECRET", "\"dev-device-provision-secret-change-me\"" } @@ -33,6 +35,10 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } + kotlinOptions { + jvmTarget = '1.8' + } + buildFeatures { dataBinding true buildConfig true @@ -116,15 +122,12 @@ android { applicationVariants.all { variant -> variant.outputs.each { output -> def buildType = variant.buildType.name - def fileName = "" if (buildType.contains("debug")) { - fileName = "${appName()}_V${defaultConfig.versionName}_${releaseTime()}.apk" + output.outputFileName = "${appName()}_V${defaultConfig.versionName}_${releaseTime()}.apk" } else { - fileName = "${appName()}_${variant.versionCode}_V${variant.versionName}_${releaseTime()}_${buildType}.apk" + output.outputFileName = "${appName()}_${variant.versionCode}_V${variant.versionName}_${releaseTime()}_${buildType}.apk" } - - output.outputFileName = fileName } } @@ -147,19 +150,57 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation "androidx.multidex:multidex:2.0.1" + implementation "androidx.recyclerview:recyclerview:1.1.0" + // Room依赖 + implementation "androidx.room:room-runtime:2.8.4" + implementation "androidx.room:room-rxjava3:2.8.4" + kapt "androidx.room:room-compiler:2.8.4" + // ViewModel和LiveData + implementation "androidx.lifecycle:lifecycle-viewmodel:2.10.0" + implementation "androidx.lifecycle:lifecycle-livedata:2.10.0" + implementation "androidx.lifecycle:lifecycle-runtime:2.10.0" + kapt "androidx.lifecycle:lifecycle-compiler:2.10.0" + // LifecycleService 核心库 + implementation "androidx.lifecycle:lifecycle-service:2.10.0" + // 安全存储:加密 SharedPreferences(保存激活得到的 deviceSecret / deviceUid / accessToken) + implementation 'androidx.security:security-crypto:1.1.0' + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + //RxJava + implementation 'io.reactivex.rxjava3:rxjava:3.1.12' + implementation 'io.reactivex.rxjava3:rxandroid:3.0.2' + + implementation 'com.squareup.moshi:moshi:1.15.2' + implementation 'com.squareup.okhttp3:okhttp:5.3.2' + implementation 'com.squareup.okhttp3:logging-interceptor:5.3.2' + implementation 'com.squareup.retrofit2:retrofit:3.0.0' + implementation 'com.squareup.retrofit2:converter-gson:3.0.0' +// implementation 'com.squareup.retrofit2:adapter-rxjava2:3.0.0' + implementation "com.squareup.retrofit2:adapter-rxjava3:3.0.0" + + // Gson for JSON(信令消息仍使用 JSON) + implementation 'com.google.code.gson:gson:2.14.0' + // Protobuf(DataChannel 控制指令二进制) + implementation 'com.google.protobuf:protobuf-java:3.25.1' + // WebRTC implementation 'io.github.webrtc-sdk:android:144.7559.09' // implementation 'org.webrtc:google-webrtc:1.0.32006' - // OkHttp for WebSocket - implementation 'com.squareup.okhttp3:okhttp:4.12.0' - // Gson for JSON(信令消息仍使用 JSON) - implementation 'com.google.code.gson:gson:2.10.1' - // Protobuf(DataChannel 控制指令二进制) - implementation 'com.google.protobuf:protobuf-java:3.25.1' - // 安全存储:加密 SharedPreferences(保存激活得到的 deviceSecret / deviceUid / accessToken) - implementation 'androidx.security:security-crypto:1.1.0-alpha06' + + //生命周期管理 + implementation 'com.trello.rxlifecycle4:rxlifecycle:4.0.2' + implementation 'com.trello.rxlifecycle4:rxlifecycle-android:4.0.2' + implementation 'com.trello.rxlifecycle4:rxlifecycle-components:4.0.2' + implementation 'com.trello.rxlifecycle4:rxlifecycle-components-preference:4.0.2' + implementation 'com.trello.rxlifecycle4:rxlifecycle-android-lifecycle:4.0.2' + //glide + implementation 'com.github.bumptech.glide:glide:4.15.1' + kapt 'com.github.bumptech.glide:compiler:4.15.1' + + implementation 'com.tencent:mmkv-static:2.4.0' + } diff --git a/WebRTCControlled/app/src/main/AndroidManifest.xml b/WebRTCControlled/app/src/main/AndroidManifest.xml index 0e99fd3..d950a8b 100644 --- a/WebRTCControlled/app/src/main/AndroidManifest.xml +++ b/WebRTCControlled/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + @@ -25,6 +27,16 @@ + + + + + + android:exported="false" + android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"> diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/activation/ActivationActivity.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/activation/ActivationActivity.java new file mode 100644 index 0000000..54cd490 --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/activation/ActivationActivity.java @@ -0,0 +1,65 @@ +package com.ttstd.controlled.activity.activation; + +import android.content.Intent; +import android.os.Build; +import android.view.View; +import android.widget.Toast; + +import com.ttstd.controlled.R; +import com.ttstd.controlled.activity.main.MainActivity; +import com.ttstd.controlled.activity.settings.SettingsActivity; +import com.ttstd.controlled.base.mvvm.BaseMvvmActivity; +import com.ttstd.controlled.databinding.ActivityActivationBinding; +import com.ttstd.controlled.utils.DeviceUtils; + +/** + * 激活页。 + * + * 设备未激活时展示提示信息,并提供「重新激活」按钮, + * 点击后重新执行 provision + token 流程,成功后跳转主页。 + */ +public class ActivationActivity extends BaseMvvmActivity { + + @Override + protected int getLayoutId() { + return R.layout.activity_activation; + } + + @Override + protected void initView() { + binding.tvMessage.setText(R.string.activation_hint); + binding.tvDeviceInfo.setText( + getString(R.string.activation_device_info, Build.MODEL, DeviceUtils.getStableId(this))); + + binding.btnRetry.setOnClickListener(v -> viewModel.activate()); + binding.btnSettings.setOnClickListener(v -> + startActivity(new Intent(this, SettingsActivity.class))); + } + + @Override + protected void initData() { + viewModel.init(this); + + viewModel.getLoading().observe(this, loading -> { + boolean isLoading = Boolean.TRUE.equals(loading); + binding.progressBar.setVisibility(isLoading ? View.VISIBLE : View.GONE); + binding.btnRetry.setEnabled(!isLoading); + if (isLoading) { + binding.tvMessage.setText(R.string.activation_activating); + } + }); + + viewModel.getSuccess().observe(this, success -> { + if (!Boolean.TRUE.equals(success)) return; + Toast.makeText(this, R.string.activation_success, Toast.LENGTH_SHORT).show(); + startActivity(new Intent(this, MainActivity.class)); + finish(); + }); + + viewModel.getError().observe(this, reason -> + binding.tvMessage.setText(getString(R.string.activation_failed, reason))); + + // 进入页面即自动尝试激活一次,失败后用户可点击按钮重试。 + viewModel.activate(); + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/activation/ActivationViewModel.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/activation/ActivationViewModel.java new file mode 100644 index 0000000..4676244 --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/activation/ActivationViewModel.java @@ -0,0 +1,69 @@ +package com.ttstd.controlled.activity.activation; + +import android.content.Context; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.ttstd.controlled.base.mvvm.BaseViewModel; +import com.ttstd.controlled.network.DeviceRepository; + +/** + * 激活页 ViewModel。 + * + * 通过 Retrofit + RxJava3 执行 provision + token 流程, + * 订阅由 BaseViewModel 的 CompositeDisposable 托管,页面销毁时自动取消。 + */ +public class ActivationViewModel extends BaseViewModel { + + /** 是否正在激活(控制进度条与按钮可用性)。 */ + private final MutableLiveData loading = new MutableLiveData<>(false); + /** 激活成功事件。 */ + private final MutableLiveData success = new MutableLiveData<>(); + /** 激活失败原因。 */ + private final MutableLiveData error = new MutableLiveData<>(); + + private DeviceRepository repository; + + public LiveData getLoading() { + return loading; + } + + public LiveData getSuccess() { + return success; + } + + public LiveData getError() { + return error; + } + + public void init(Context context) { + if (repository == null) { + repository = new DeviceRepository(context); + } + } + + /** + * 点击「重新激活」:清空本地旧凭据后走完整 provision 流程。 + */ + public void activate() { + if (repository == null) { + error.setValue("仓库未初始化"); + return; + } + if (Boolean.TRUE.equals(loading.getValue())) { + return; // 防重复点击 + } + loading.setValue(true); + execute(repository.reactivate(), + token -> { + loading.setValue(false); + success.setValue(true); + }, + e -> { + loading.setValue(false); + String msg = e != null ? e.getMessage() : null; + error.setValue(msg == null || msg.isEmpty() ? "未知错误" : msg); + }); + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java index aa53635..8e21c41 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java @@ -10,12 +10,10 @@ import android.content.pm.PackageManager; import android.media.projection.MediaProjectionManager; import android.os.Build; import android.os.IBinder; -import android.provider.Settings; import android.util.Log; import android.view.View; import android.widget.AdapterView; import android.widget.ArrayAdapter; -import android.widget.Spinner; import android.widget.Toast; import java.util.ArrayList; @@ -28,12 +26,10 @@ import androidx.core.content.ContextCompat; import com.ttstd.controlled.R; import com.ttstd.controlled.accessibility.AccessibilityServiceHelper; -import com.ttstd.controlled.base.BaseMvvmActivity; +import com.ttstd.controlled.base.mvvm.BaseMvvmActivity; import com.ttstd.controlled.databinding.ActivityMainBinding; import com.ttstd.controlled.activity.settings.SettingsActivity; import com.ttstd.controlled.service.ScreenCaptureService; -import com.ttstd.controlled.accessibility.KeyboardAccessibilityService; -import com.ttstd.controlled.utils.DeviceUtils; import com.ttstd.controlled.utils.SignatureUtils; import com.ttstd.controlled.webrtc.WebRtcClient; @@ -86,8 +82,6 @@ public class MainActivity extends BaseMvvmActivity= Build.VERSION_CODES.O) { @@ -329,7 +323,6 @@ public class MainActivity extends BaseMvvmActivity { + + /** 启动页最短停留时间,避免闪屏一闪而过。 */ + private static final long MIN_SPLASH_MS = 800L; + + @Override + protected int getLayoutId() { + return R.layout.activity_splash; + } + + @Override + protected void initView() { + binding.tvStatus.setText(R.string.splash_checking); + } + + @Override + protected void initData() { + viewModel.getActivated().observe(this, activated -> { + // 保证启动页至少展示 MIN_SPLASH_MS,观感更自然。 + binding.getRoot().postDelayed(() -> { + if (isFinishing() || isDestroyed()) return; + Intent intent = Boolean.TRUE.equals(activated) + ? new Intent(this, MainActivity.class) + : new Intent(this, ActivationActivity.class); + startActivity(intent); + finish(); + }, MIN_SPLASH_MS); + }); + viewModel.check(this); + } + + @Override + public void onBackPressed() { + // 启动页检查期间屏蔽返回,避免produce中间态。 + // 不调用 super,直接忽略。 + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/splash/SplashViewModel.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/splash/SplashViewModel.java new file mode 100644 index 0000000..5743c7b --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/splash/SplashViewModel.java @@ -0,0 +1,35 @@ +package com.ttstd.controlled.activity.splash; + +import android.app.Application; +import android.content.Context; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.ttstd.controlled.base.mvvm.BaseViewModel; +import com.ttstd.controlled.network.DeviceRepository; + +/** + * 启动页 ViewModel:判断设备是否已激活。 + * + * 已激活 → 进入 MainActivity;未激活 → 进入 ActivationActivity。 + */ +public class SplashViewModel extends BaseViewModel { + + /** true=已激活,进入主页;false=未激活,进入激活页。 */ + private final MutableLiveData activated = new MutableLiveData<>(); + + private DeviceRepository repository; + + public LiveData getActivated() { + return activated; + } + + public void check(Context context) { + if (repository == null) { + repository = new DeviceRepository(context); + } + // 本地凭据判断为纯内存/SP 读取,无需网络请求。 + activated.setValue(repository.isActivated()); + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/base/BaseApplication.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/base/BaseApplication.java index 3aebfbf..6dffb46 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/base/BaseApplication.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/base/BaseApplication.java @@ -1,50 +1,17 @@ -package com.ttstd.dialer.base; +package com.ttstd.controlled.base; import android.annotation.SuppressLint; import android.app.Application; import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.os.Build; -import android.os.Handler; -import android.os.Looper; +import android.util.Log; import androidx.multidex.MultiDex; -import com.alibaba.android.arouter.launcher.ARouter; -import com.arialyy.aria.core.Aria; -import com.kongzue.dialogx.DialogX; -import com.tencent.bugly.crashreport.CrashReport; import com.tencent.mmkv.MMKV; -import com.ttstd.dialer.BuildConfig; -import com.ttstd.dialer.alarmclock.AlarmManagerHelper; -import com.ttstd.dialer.config.CommonConfig; -import com.ttstd.dialer.config.SystemIntentAction; -import com.ttstd.dialer.data.cache.CacheManager; -import com.ttstd.dialer.manager.AppManager; -import com.ttstd.dialer.manager.MapManager; -import com.ttstd.dialer.manager.WeatherManager; -import com.ttstd.dialer.mdm.DeviceManagerService; -import com.ttstd.dialer.network.OkHttpManager; -import com.ttstd.dialer.push.PushExecutor; -import com.ttstd.dialer.receiver.AppChangedReceiver; -import com.ttstd.dialer.receiver.HourlyChimeManager; -import com.ttstd.dialer.tts.sherpa_onnx.SherpaOnnxTtsManager; -import com.ttstd.dialer.utils.Logger; -import com.ttstd.dialer.utils.NativeUtils; -import com.ttstd.dialer.utils.SystemUtils; -import com.ttstd.iconloader.IconCacheManager; - -import cn.jiguang.api.JCoreInterface; -import cn.jiguang.api.utils.JCollectionAuth; -import cn.jpush.android.api.JPushInterface; - public class BaseApplication extends Application { private static final String TAG = "BaseApplication"; - private MMKV mMMKV; - /** * ViewModel中因为经常旋转导致弱引用为空 */ @@ -64,23 +31,15 @@ public class BaseApplication extends Application { @Override public void onCreate() { super.onCreate(); - Logger.e(TAG, "onCreate: "); + Log.e(TAG, "onCreate: "); mAppContext = getApplicationContext(); - if (!BuildConfig.DEBUG) { - catchException(); - } - // 在开始分析的地方调用,传入路径 - // 如果是放到外部路径,需要添加权限 - // 默认存储在/sdcard/Android/data/packagename/files -// Debug.startMethodTracing("App" + System.currentTimeMillis()); init(); } @Override public void onTerminate() { super.onTerminate(); - unregisterReceivers(); } @Override @@ -94,142 +53,9 @@ public class BaseApplication extends Application { } private void init() { - Logger.e(TAG, "init: "); - Logger.e(TAG, "init: getNonce = " + NativeUtils.getNonce()); - if (SystemUtils.isMainProcessName(this, android.os.Process.myPid())) { - Logger.initialize(this, BuildConfig.DEBUG); - Logger.setLogLevel(Logger.LogLevel.DEBUG); // 开发阶段记录所有日志 - - String rootDir = MMKV.initialize(this); - Logger.e(TAG, "mmkv root: " + rootDir); - mMMKV = MMKV.mmkvWithID(CommonConfig.MMKV_ID, MMKV.MULTI_PROCESS_MODE); - - DeviceManagerService.init(this); - OkHttpManager.init(this); - PushExecutor.init(this); - initJPush(); - - if (BuildConfig.DEBUG) { // 这两行必须写在init之前,否则这些配置在init过程中将无效 - ARouter.openLog(); // 打印日志 - ARouter.openDebug(); // 开启调试模式(如果在InstantRun模式下运行,必须开启调试模式!线上版本需要关闭,否则有安全风险) - } - ARouter.init(this); // 尽可能早,推荐在Application中初始化 - - DialogX.init(this); - - Logger.e(TAG, "slowInit: "); - Aria.init(this); - CrashReport.initCrashReport(getApplicationContext(), "845e3ed68c", false); - CrashReport.setDeviceId(this, Build.MODEL); - xcrash.XCrash.init(this); - - AppManager.init(this); - MapManager.init(this); - MapManager.getInstance().initMap(); - MapManager.getInstance().startLocation(); - - WeatherManager.init(this); - IconCacheManager.init(this); - SherpaOnnxTtsManager.getInstance().init(this); - AlarmManagerHelper.rescheduleAllAlarms(this); - - if (mMMKV.decodeInt(CommonConfig.HOURLY_CHIME_ENABLE, 0) == 1) { - HourlyChimeManager.startChime(this); - } - - registerReceivers(); - - // 启动时异步清理磁盘缓存(过期淘汰 + 容量上限),不阻塞启动 - CacheManager.getInstance(this).cleanupAsync(); - } - } - - private void initJPush() { - /*jpush start*/ - JPushInterface.setDebugMode(true); - // 调整点一:调用启用推送业务功能代码前增加setAuth调用 - boolean isPrivacyReady = true; // app根据是否已弹窗获取隐私授权来赋值 - if (!isPrivacyReady) { - // JCore 5.0.4之前版本需要显式设置false - if (JCoreInterface.getJCoreSDKVersionInt() < 504) { // 5.0.4版本号对应504 - JCollectionAuth.setAuth(this, false); - } - // 所有版本在未授权时都不应初始化SDK - return; - } - JPushInterface.init(this); - JPushInterface.setAlias(this, 0, DeviceManagerService.getInstance().getSerial()); - - // 调整点二:App用户同意了隐私政策授权,并且开发者确定要开启推送服务后调用 - // JCore 5.0.4+会自动处理授权状态,可不需要显式设置true - JCollectionAuth.setAuth(this, true); - /*jpush end*/ - Logger.e(TAG, "initJPush: inited JPush"); - - } - - private void catchException() { - Thread.setDefaultUncaughtExceptionHandler( - new Thread.UncaughtExceptionHandler() { - @Override - public void uncaughtException(Thread t, Throwable e) { - Logger.e("捕获异常子线程:", Thread.currentThread().getName() + - "在:" + e.getStackTrace()[0].getClassName()); - } - } - ); - //下面是新增方法! - new Handler(Looper.getMainLooper()).post(new Runnable() { - @Override - public void run() { - while (true) { - try { - Looper.loop(); //会先执行这个方法,然后在执行下面的异常捕获方法! - } catch (Exception e) { - Logger.e("捕获异常主线程:", Thread.currentThread().getName() + "在:" + e.getStackTrace()[0].getClassName()); - e.printStackTrace(); - } - } - } - }); - } - - private void registerReceivers() { - registerAppChangedReceive(); - } - - @Deprecated - private void unregisterReceivers() { - if (mAppChangedReceiver != null) { - unregisterReceiver(mAppChangedReceiver); - } - } - - private AppChangedReceiver mAppChangedReceiver; - - - private void registerAppChangedReceive() { - if (null == mAppChangedReceiver) { - mAppChangedReceiver = new AppChangedReceiver(); - } - IntentFilter filter = new IntentFilter(); - filter.setPriority(IntentFilter.SYSTEM_HIGH_PRIORITY); - filter.addAction(Intent.ACTION_PACKAGE_INSTALL); - filter.addAction(Intent.ACTION_PACKAGE_ADDED); - filter.addAction(Intent.ACTION_PACKAGE_REPLACED); - filter.addAction(Intent.ACTION_MY_PACKAGE_REPLACED); - filter.addAction(Intent.ACTION_PACKAGE_REMOVED); - filter.addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED); - filter.addAction(Intent.ACTION_PACKAGE_CHANGED); - filter.addAction(SystemIntentAction.ACTION_PACKAGE_ENABLE_ROLLBACK); - filter.addAction(SystemIntentAction.ACTION_CANCEL_ENABLE_ROLLBACK); - filter.addAction(SystemIntentAction.ACTION_ROLLBACK_COMMITTED); - filter.addDataScheme("package"); - registerReceiver(mAppChangedReceiver, filter); - - // 监听系统语言切换,刷新桌面应用名称(该广播不带 package data,需单独注册) - IntentFilter localeFilter = new IntentFilter(Intent.ACTION_LOCALE_CHANGED); - registerReceiver(mAppChangedReceiver, localeFilter); + Log.e(TAG, "init: "); + String rootDir = MMKV.initialize(this); + Log.e(TAG, "mmkv root: " + rootDir); } } diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/DeviceApi.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/DeviceApi.java new file mode 100644 index 0000000..b7f2213 --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/DeviceApi.java @@ -0,0 +1,40 @@ +package com.ttstd.controlled.network; + +import com.google.gson.JsonObject; +import com.ttstd.controlled.network.model.ProvisionRequest; +import com.ttstd.controlled.network.model.ProvisionResponse; +import com.ttstd.controlled.network.model.TokenRequest; +import com.ttstd.controlled.network.model.TokenResponse; + +import io.reactivex.rxjava3.core.Single; +import retrofit2.http.Body; +import retrofit2.http.GET; +import retrofit2.http.Header; +import retrofit2.http.POST; + +/** + * 被控端设备侧接口(Retrofit 声明式定义)。 + * + * 全部返回 RxJava3 的 {@link Single},由调用方 compose 生命周期绑定, + * 从而在 Activity/Service 销毁时自动取消请求,不再使用裸 new Thread。 + */ +public interface DeviceApi { + + /** + * 激活第一步:用出厂 SN + HMAC 签名证明设备身份,换取 deviceUid 与一次性 deviceSecret。 + */ + @POST("/api/device/provision") + Single provision(@Body ProvisionRequest request); + + /** + * 激活第二步:用 deviceUid + deviceSecret 换取 accessToken(WebSocket Bearer 握手使用)。 + */ + @POST("/api/device/token") + Single token(@Body TokenRequest request); + + /** + * 拉取 TURN 短期凭证(iceServers)。服务端未开启时可能返回错误,调用方需自行降级。 + */ + @GET("/api/client/turn-credentials") + Single turnCredentials(@Header("Authorization") String bearerToken); +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/DeviceRepository.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/DeviceRepository.java new file mode 100644 index 0000000..55368aa --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/DeviceRepository.java @@ -0,0 +1,125 @@ +package com.ttstd.controlled.network; + +import android.content.Context; +import android.os.Build; +import android.text.TextUtils; + +import com.google.gson.JsonObject; +import com.ttstd.controlled.BuildConfig; +import com.ttstd.controlled.network.model.ProvisionRequest; +import com.ttstd.controlled.network.model.ProvisionResponse; +import com.ttstd.controlled.network.model.TokenRequest; +import com.ttstd.controlled.network.model.TokenResponse; +import com.ttstd.controlled.utils.DeviceSecretStore; +import com.ttstd.controlled.utils.DeviceUtils; + +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * 设备激活 / 令牌仓库。 + * + * 统一封装 provision + token 两步流程与凭据落盘,所有方法返回冷 Single, + * 订阅时才发起请求,交由调用方(ViewModel)用 CompositeDisposable 管理生命周期。 + */ +public class DeviceRepository { + + /** 出厂预置共享密钥,由 build.gradle 的 buildConfigField 注入。 */ + private static final String PROVISION_SECRET = BuildConfig.DEVICE_PROVISION_SECRET; + + private final DeviceApi api; + private final DeviceSecretStore store; + private final Context appContext; + + public DeviceRepository(Context context) { + this.appContext = context.getApplicationContext(); + this.api = RetrofitClient.deviceApi(); + this.store = new DeviceSecretStore(this.appContext); + } + + public DeviceSecretStore store() { + return store; + } + + public boolean isActivated() { + return store.isActivated(); + } + + public String getDeviceUid() { + return store.getDeviceUid(); + } + + public String getAccessToken() { + return store.getAccessToken(); + } + + /** + * 完整激活流程:provision 拿到 deviceUid/deviceSecret 并落盘,随后换取 accessToken。 + * + * 若设备已激活则跳过 provision,直接用已有凭据换 token(即「刷新」语义)。 + * + * @return 发射最终可用的 accessToken + */ + public Single activate() { + return Single.defer(() -> { + if (store.isActivated()) { + return exchangeToken(store.getDeviceUid(), store.getDeviceSecret()); + } + return provision().flatMap(resp -> exchangeToken(resp.getDeviceUid(), resp.getDeviceSecret())); + }).subscribeOn(Schedulers.io()); + } + + /** 强制重新激活:清空本地凭据后走完整 provision 流程。用于激活页「刷新重试」。 */ + public Single reactivate() { + return Single.defer(() -> { + store.clear(); + return provision().flatMap(resp -> exchangeToken(resp.getDeviceUid(), resp.getDeviceSecret())); + }).subscribeOn(Schedulers.io()); + } + + /** 第一步:用 SN + HMAC 证明出厂身份,成功后立即加密落盘。 */ + private Single provision() { + return Single.fromCallable(() -> { + String sn = DeviceUtils.getStableId(appContext); + if (TextUtils.isEmpty(sn)) { + throw new IllegalStateException("无法获取设备序列号,无法激活"); + } + long timestamp = System.currentTimeMillis() / 1000L; + String nonce = Long.toHexString(System.nanoTime()) + Long.toHexString(System.currentTimeMillis()); + String hmac = HmacSigner.signProvision(PROVISION_SECRET, sn, nonce, timestamp); + return new ProvisionRequest(sn, Build.MODEL, nonce, timestamp, hmac); + }).flatMap(api::provision).map(resp -> { + if (resp == null || !resp.isValid()) { + throw new IllegalStateException("激活失败:服务端未返回有效的设备凭据"); + } + store.saveDevice(resp.getDeviceUid(), resp.getDeviceSecret()); + return resp; + }); + } + + /** 第二步:用 deviceUid + deviceSecret 换取 accessToken 并落盘。 */ + private Single exchangeToken(String deviceUid, String deviceSecret) { + if (TextUtils.isEmpty(deviceUid) || TextUtils.isEmpty(deviceSecret)) { + return Single.error(new IllegalStateException("设备凭据缺失,请重新激活")); + } + return api.token(new TokenRequest(deviceUid, deviceSecret)).map(resp -> { + if (resp == null || !resp.isValid()) { + throw new IllegalStateException("令牌换取失败:服务端未返回 accessToken"); + } + store.saveAccessToken(resp.getAccessToken()); + return resp.getAccessToken(); + }); + } + + /** + * 拉取 TURN 凭证。服务端未开启或失败时返回 null(降级为仅 STUN),不中断主流程。 + */ + public Single fetchTurnCredentials(String accessToken) { + if (TextUtils.isEmpty(accessToken)) { + return Single.just(new JsonObject()); + } + return api.turnCredentials("Bearer " + accessToken) + .onErrorReturnItem(new JsonObject()) + .subscribeOn(Schedulers.io()); + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/HmacSigner.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/HmacSigner.java new file mode 100644 index 0000000..a888d97 --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/HmacSigner.java @@ -0,0 +1,36 @@ +package com.ttstd.controlled.network; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * provision 签名工具。 + * + * 签名规则须与服务端保持一致:HMAC-SHA256(secret, sn + "|" + nonce + "|" + timestamp), + * 输出小写十六进制字符串。 + */ +public final class HmacSigner { + + private HmacSigner() { + } + + public static String signProvision(String secret, String sn, String nonce, long timestamp) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + String data = sn + "|" + nonce + "|" + timestamp; + byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(raw.length * 2); + for (byte b : raw) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new IllegalStateException("HMAC 计算失败", e); + } + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/RetrofitClient.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/RetrofitClient.java new file mode 100644 index 0000000..7e2f13c --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/RetrofitClient.java @@ -0,0 +1,89 @@ +package com.ttstd.controlled.network; + +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.ttstd.controlled.BuildConfig; + +import java.util.concurrent.TimeUnit; + +import okhttp3.OkHttpClient; +import okhttp3.logging.HttpLoggingInterceptor; +import retrofit2.Retrofit; +import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory; +import retrofit2.converter.gson.GsonConverterFactory; + +/** + * 全局唯一的 Retrofit / OkHttp 实例。 + * + * OkHttpClient 内部维护连接池与线程池,必须全局复用,切勿每次请求都新建。 + */ +public final class RetrofitClient { + + private static volatile Retrofit retrofit; + private static volatile DeviceApi deviceApi; + private static volatile OkHttpClient okHttpClient; + + private RetrofitClient() { + } + + public static OkHttpClient okHttp() { + if (okHttpClient == null) { + synchronized (RetrofitClient.class) { + if (okHttpClient == null) { + OkHttpClient.Builder builder = new OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .retryOnConnectionFailure(true); + if (BuildConfig.DEBUG) { + HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); + logging.setLevel(HttpLoggingInterceptor.Level.BODY); + builder.addInterceptor(logging); + } + okHttpClient = builder.build(); + } + } + } + return okHttpClient; + } + + private static Retrofit retrofit() { + if (retrofit == null) { + synchronized (RetrofitClient.class) { + if (retrofit == null) { + // 服务端字段为 camelCase,与实体字段一致,这里显式声明以免全局 Gson 配置影响。 + Gson gson = new GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.IDENTITY) + .create(); + retrofit = new Retrofit.Builder() + .baseUrl(normalizeBaseUrl(BuildConfig.API_BASE)) + .client(okHttp()) + .addConverterFactory(GsonConverterFactory.create(gson)) + .addCallAdapterFactory(RxJava3CallAdapterFactory.create()) + .build(); + } + } + } + return retrofit; + } + + public static DeviceApi deviceApi() { + if (deviceApi == null) { + synchronized (RetrofitClient.class) { + if (deviceApi == null) { + deviceApi = retrofit().create(DeviceApi.class); + } + } + } + return deviceApi; + } + + /** Retrofit 要求 baseUrl 必须以 "/" 结尾。 */ + private static String normalizeBaseUrl(String base) { + if (base == null || base.isEmpty()) { + return "https://www.ttstd.com/"; + } + return base.endsWith("/") ? base : base + "/"; + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/model/ProvisionRequest.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/model/ProvisionRequest.java new file mode 100644 index 0000000..dabfe86 --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/model/ProvisionRequest.java @@ -0,0 +1,39 @@ +package com.ttstd.controlled.network.model; + +/** provision 请求体:SN + 随机 nonce + 时间戳 + HMAC 签名。 */ +public class ProvisionRequest { + + private final String sn; + private final String model; + private final String nonce; + private final long timestamp; + private final String hmac; + + public ProvisionRequest(String sn, String model, String nonce, long timestamp, String hmac) { + this.sn = sn; + this.model = model; + this.nonce = nonce; + this.timestamp = timestamp; + this.hmac = hmac; + } + + public String getSn() { + return sn; + } + + public String getModel() { + return model; + } + + public String getNonce() { + return nonce; + } + + public long getTimestamp() { + return timestamp; + } + + public String getHmac() { + return hmac; + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/model/ProvisionResponse.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/model/ProvisionResponse.java new file mode 100644 index 0000000..9fd113a --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/model/ProvisionResponse.java @@ -0,0 +1,26 @@ +package com.ttstd.controlled.network.model; + +/** + * provision 响应体。 + * + * 注意:deviceSecret 仅在激活成功时返回这一次,必须立即加密落盘。 + */ +public class ProvisionResponse { + + private String deviceUid; + private String deviceSecret; + + public String getDeviceUid() { + return deviceUid; + } + + public String getDeviceSecret() { + return deviceSecret; + } + + /** 是否为有效的激活结果(两个关键字段都不能为空)。 */ + public boolean isValid() { + return deviceUid != null && !deviceUid.isEmpty() + && deviceSecret != null && !deviceSecret.isEmpty(); + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/model/TokenRequest.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/model/TokenRequest.java new file mode 100644 index 0000000..236c648 --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/model/TokenRequest.java @@ -0,0 +1,21 @@ +package com.ttstd.controlled.network.model; + +/** token 请求体:用已落盘的设备凭据换取 accessToken。 */ +public class TokenRequest { + + private final String deviceUid; + private final String deviceSecret; + + public TokenRequest(String deviceUid, String deviceSecret) { + this.deviceUid = deviceUid; + this.deviceSecret = deviceSecret; + } + + public String getDeviceUid() { + return deviceUid; + } + + public String getDeviceSecret() { + return deviceSecret; + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/model/TokenResponse.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/model/TokenResponse.java new file mode 100644 index 0000000..5be61f7 --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/model/TokenResponse.java @@ -0,0 +1,15 @@ +package com.ttstd.controlled.network.model; + +/** token 响应体:accessToken 无 refreshToken,失效后重新换取。 */ +public class TokenResponse { + + private String accessToken; + + public String getAccessToken() { + return accessToken; + } + + public boolean isValid() { + return accessToken != null && !accessToken.isEmpty(); + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureModel.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureModel.java index cf55b96..33073f6 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureModel.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureModel.java @@ -1,60 +1,106 @@ package com.ttstd.controlled.service; +import android.content.Context; import android.util.Log; -import androidx.lifecycle.ViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; -import com.trello.rxlifecycle4.LifecycleTransformer; -import com.trello.rxlifecycle4.RxLifecycle; -import com.trello.rxlifecycle4.android.ActivityEvent; -import com.ttstd.dialer.bean.BaseResponse; -import com.ttstd.dialer.bean.req.SnLocationReq; -import com.ttstd.dialer.network.BaseObserver; -import com.ttstd.dialer.network.OkHttpManager; +import com.ttstd.controlled.base.mvvm.BaseViewModel; +import com.ttstd.controlled.network.DeviceRepository; -import io.reactivex.rxjava3.subjects.BehaviorSubject; +/** + * ScreenCaptureService 的 ViewModel。 + * + * 负责设备激活(provision + token)与令牌刷新的全部网络交互, + * 通过 Retrofit + RxJava3 发起请求,并由 BaseViewModel 的 CompositeDisposable + * 在 onCleared(Service 销毁)时自动取消,不再使用 new Thread。 + */ +public class ScreenCaptureModel extends BaseViewModel { -public class ScreenCaptureModel extends ViewModel { - private static final String TAG = "MainServiceModel"; + private static final String TAG = "ScreenCaptureModel"; - private BehaviorSubject lifecycleSubject; + private DeviceRepository repository; - // 设置Service生命周期Subject - public void setLifecycleSubject(BehaviorSubject lifecycleSubject) { - this.lifecycleSubject = lifecycleSubject; - } + /** 激活成功,携带 accessToken。 */ + private final MutableLiveData activateSuccess = new MutableLiveData<>(); + /** 激活失败,携带原因。 */ + private final MutableLiveData activateFailed = new MutableLiveData<>(); + /** 令牌刷新成功,携带新的 accessToken。 */ + private final MutableLiveData tokenRefreshed = new MutableLiveData<>(); + /** 令牌刷新失败,携带原因。 */ + private final MutableLiveData tokenRefreshFailed = new MutableLiveData<>(); - // 绑定请求到Service销毁事件(自动取消请求) - private LifecycleTransformer bindToLifecycle() { - if (lifecycleSubject == null) { - throw new IllegalStateException("请先设置LifecycleSubject!"); + public void init(Context context) { + if (repository == null) { + repository = new DeviceRepository(context); } - return RxLifecycle.bindUntilEvent(lifecycleSubject, ActivityEvent.DESTROY); } - @Override - protected void onCleared() { - super.onCleared(); - // 清空生命周期引用,防止内存泄漏 - this.lifecycleSubject = null; + public LiveData getActivateSuccess() { + return activateSuccess; } + public LiveData getActivateFailed() { + return activateFailed; + } - public void uploadLocation(SnLocationReq locationReq) { - Log.e(TAG, "uploadLocation: "); - OkHttpManager.getInstance().getUploadLocationObservable(locationReq) - .compose(bindToLifecycle()) - .subscribe(new BaseObserver() { - @Override - public void onSuccess(BaseResponse baseResponse) { - Log.e("uploadLocation", "onSuccess: " + baseResponse); - } + public LiveData getTokenRefreshed() { + return tokenRefreshed; + } - @Override - public void onFailure(Throwable e) { - Log.e("uploadLocation", "onFailure: " + e.getMessage()); - } + public LiveData getTokenRefreshFailed() { + return tokenRefreshFailed; + } + + public String getDeviceUid() { + return repository != null ? repository.getDeviceUid() : null; + } + + public boolean isActivated() { + return repository != null && repository.isActivated(); + } + + /** + * 确保设备已激活:已激活则直接换取 accessToken,否则先 provision 再换取。 + */ + public void activate() { + if (repository == null) { + activateFailed.setValue("仓库未初始化"); + return; + } + execute(repository.activate(), + token -> { + Log.i(TAG, "激活成功,deviceUid=" + repository.getDeviceUid()); + activateSuccess.setValue(token); + }, + error -> { + Log.e(TAG, "激活失败", error); + activateFailed.setValue(message(error)); }); + } + /** + * 令牌失效(4001)时重新换取 accessToken。 + */ + public void refreshToken() { + if (repository == null) { + tokenRefreshFailed.setValue("仓库未初始化"); + return; + } + execute(repository.activate(), + token -> { + Log.i(TAG, "令牌刷新成功"); + tokenRefreshed.setValue(token); + }, + error -> { + Log.e(TAG, "令牌刷新失败", error); + tokenRefreshFailed.setValue(message(error)); + }); + } + + private static String message(Throwable error) { + String msg = error != null ? error.getMessage() : null; + return msg == null || msg.isEmpty() ? "未知错误" : msg; } } diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java index e9b8d66..b1aa60e 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java @@ -22,6 +22,7 @@ import android.widget.Toast; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; +import androidx.lifecycle.ViewModelProvider; import com.google.gson.Gson; import com.google.gson.JsonObject; @@ -31,18 +32,16 @@ import com.ttstd.controlled.R; import com.ttstd.controlled.accessibility.KeyboardAccessibilityService; import com.ttstd.controlled.activity.connection.ConnectionRequestActivity; import com.ttstd.controlled.activity.main.MainActivity; +import com.ttstd.controlled.base.BaseService; import com.ttstd.controlled.input.AccessibilityInputUtils; import com.ttstd.controlled.input.InputCommandHandler; import com.ttstd.controlled.input.InputExecutor; import com.ttstd.controlled.input.RootShellInputUtils; import com.ttstd.controlled.input.ShellInputUtils; import com.ttstd.controlled.input.SystemInputUtils; -import com.ttstd.controlled.signaling.ApiClient; import com.ttstd.controlled.signaling.SignalMessage; import com.ttstd.controlled.signaling.WebSocketClient; import com.ttstd.controlled.utils.AuthSettings; -import com.ttstd.controlled.utils.DeviceSecretStore; -import com.ttstd.controlled.utils.DeviceUtils; import com.ttstd.controlled.utils.InputSettings; import com.ttstd.controlled.utils.SignatureUtils; import com.ttstd.controlled.webrtc.SelfCodecEncoder; @@ -55,7 +54,7 @@ import org.webrtc.ScreenCapturerAndroid; import java.util.ArrayList; import java.util.List; -public class ScreenCaptureService extends Service { +public class ScreenCaptureService extends BaseService { private static final String TAG = "ScreenCaptureService"; private static final String CHANNEL_ID = "screen_capture_channel"; @@ -66,6 +65,8 @@ public class ScreenCaptureService extends Service { public static final String EXTRA_SERVER_URL = "server_url"; public static final String EXTRA_DEVICE_ID = "device_id"; + private ScreenCaptureModel mViewModel; + /** * 静态授权结果,解决部分 Android 10 设备跨进程/Intent 传递 Intent 时 Token 失效的问题 */ @@ -99,12 +100,10 @@ public class ScreenCaptureService extends Service { */ private String deviceId; - /** 被控端凭据安全存储(deviceUid / deviceSecret / accessToken)。 */ - private DeviceSecretStore secretStore; - /** 激活与令牌 HTTP 客户端。 */ - private ApiClient apiClient; /** 当前 accessToken(Bearer 握手用),失效后重新换取。 */ private String accessToken; + /** 待连接的信令服务器地址,激活/刷新令牌完成后使用。 */ + private String pendingServerUrl; /** 信令监听器引用,重连时复用。 */ private WebSocketClient.SignalListener signalListener; /** @@ -150,10 +149,41 @@ public class ScreenCaptureService extends Service { @Override public void onCreate() { super.onCreate(); + // 初始化 ViewModel:网络请求统一由其内部的 Retrofit + CompositeDisposable 管理, + // 通过 ViewModelProvider 创建,Service 销毁时自动 onCleared 取消在途请求。 + mViewModel = new ViewModelProvider(this).get(ScreenCaptureModel.class); + mViewModel.init(this); + observeViewModel(); + instance = this; createNotificationChannel(); } + /** + * 订阅 ViewModel 的激活 / 令牌事件。 + * BaseService 实现了 LifecycleOwner,observe 会随 Service 销毁自动解绑。 + */ + private void observeViewModel() { + mViewModel.getActivateSuccess().observe(this, token -> { + accessToken = token; + if (pendingServerUrl != null) { + beginScreenCapture(pendingServerUrl); + } + }); + mViewModel.getActivateFailed().observe(this, this::notifyActivationFailed); + mViewModel.getTokenRefreshed().observe(this, token -> { + accessToken = token; + if (wsClient != null) wsClient.disconnect(); + // deviceId 不变,accessToken 已更新;重新连接会带上新令牌。 + wsClient = new WebSocketClient(pendingServerUrl, accessToken, signalListener); + wsClient.connect(); + }); + mViewModel.getTokenRefreshFailed().observe(this, reason -> { + if (stateListener != null) stateListener.onError("令牌刷新失败: " + reason); + stopSelf(); + }); + } + @Override public int onStartCommand(Intent intent, int flags, int startId) { if (intent == null) { @@ -270,42 +300,9 @@ public class ScreenCaptureService extends Service { * - 网络操作在后台线程进行,完成后切回主线程启动屏幕采集与信令连接。 */ private void ensureActivatedThenConnect(String serverUrl) { - if (secretStore == null) { - secretStore = new DeviceSecretStore(this); - apiClient = new ApiClient(); - } - new Thread(() -> { - try { - if (!secretStore.isActivated()) { - String sn = DeviceUtils.getStableId(this); - if (sn == null || sn.isEmpty()) { - notifyActivationFailed("无法读取设备 SN(请确认系统签名或授予必要权限)"); - return; - } - JsonObject provision = apiClient.provision(sn, Build.MODEL); - String deviceUid = provision.has("deviceUid") ? provision.get("deviceUid").getAsString() : null; - String deviceSecret = provision.has("deviceSecret") ? provision.get("deviceSecret").getAsString() : null; - if (deviceUid == null || deviceSecret == null) { - notifyActivationFailed("激活返回数据缺失"); - return; - } - // deviceSecret 仅返回一次,立即加密落盘。 - secretStore.saveDevice(deviceUid, deviceSecret); - Log.i(TAG, "provision 成功,deviceUid=" + deviceUid); - } - // 换取 accessToken。 - JsonObject tokenResp = apiClient.token(secretStore.getDeviceUid(), secretStore.getDeviceSecret()); - accessToken = tokenResp.has("accessToken") ? tokenResp.get("accessToken").getAsString() : null; - if (accessToken == null) { - notifyActivationFailed("令牌换取失败"); - return; - } - secretStore.saveAccessToken(accessToken); - mainHandler.post(() -> beginScreenCapture(serverUrl)); - } catch (Exception e) { - notifyActivationFailed(e.getMessage()); - } - }).start(); + this.pendingServerUrl = serverUrl; + // 交由 ViewModel 走 Retrofit 请求,结果通过 LiveData 回到主线程。 + mViewModel.activate(); } /** 通知 UI 激活失败(主线程调用)。 */ @@ -321,7 +318,7 @@ public class ScreenCaptureService extends Service { /** 激活成功后:延时启动屏幕采集并连接信令服务器。 */ private void beginScreenCapture(String serverUrl) { if (isShuttingDown) return; - this.deviceId = secretStore.getDeviceUid(); + this.deviceId = mViewModel.getDeviceUid(); final int finalCaptureWidth = currentCaptureWidth; final int finalCaptureHeight = currentCaptureHeight; final int finalFps = currentCaptureFps; @@ -332,33 +329,10 @@ public class ScreenCaptureService extends Service { }, 200); } - /** 令牌失效(4001):后台线程重新换取 accessToken,成功后重连。 */ + /** 令牌失效(4001):通过 ViewModel 重新换取 accessToken,成功后在观察者中重连。 */ private void refreshTokenAndReconnect(String serverUrl) { - new Thread(() -> { - try { - JsonObject tokenResp = apiClient.token(secretStore.getDeviceUid(), secretStore.getDeviceSecret()); - accessToken = tokenResp.has("accessToken") ? tokenResp.get("accessToken").getAsString() : null; - if (accessToken == null) { - mainHandler.post(() -> { - if (stateListener != null) stateListener.onError("令牌刷新失败,请重新激活"); - stopSelf(); - }); - return; - } - secretStore.saveAccessToken(accessToken); - mainHandler.post(() -> { - if (wsClient != null) wsClient.disconnect(); - // deviceId 不变,accessToken 已更新;重新连接会带上新令牌。 - wsClient = new WebSocketClient(serverUrl, accessToken, signalListener); - wsClient.connect(); - }); - } catch (Exception e) { - mainHandler.post(() -> { - if (stateListener != null) stateListener.onError("令牌刷新异常: " + e.getMessage()); - stopSelf(); - }); - } - }).start(); + this.pendingServerUrl = serverUrl; + mViewModel.refreshToken(); } /** diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/signaling/ApiClient.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/signaling/ApiClient.java deleted file mode 100644 index 48cbf2c..0000000 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/signaling/ApiClient.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.ttstd.controlled.signaling; - -import android.util.Log; - -import com.google.gson.Gson; -import com.google.gson.JsonObject; -import com.ttstd.controlled.BuildConfig; - -import java.nio.charset.StandardCharsets; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.util.concurrent.TimeUnit; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; - -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import okhttp3.ResponseBody; - -/** - * 被控端 HTTP 客户端:对接安全信令服务器的激活(provision / token)与 TURN 接口。 - * - * 激活流程: - * - provision:用设备 SN + 随机 nonce + 时间戳 计算 HMAC,向服务端证明「出厂预置身份」, - * 服务端返回 deviceUid 与一次性 deviceSecret(deviceSecret 仅返回这一次,需立即安全落盘)。 - * - token:用 deviceUid + deviceSecret 换取 accessToken(用于 WebSocket Bearer 握手, - * 以及后续 TURN 凭证等受限接口)。accessToken 无 refreshToken,失效后重新走 token 换取。 - * - * 生产环境请将 PROVISION_SECRET 通过 BuildConfig / NDK 注入,切勿硬编码在源码明文。 - */ -public final class ApiClient { - - private static final String TAG = "ControlledApiClient"; - private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); - // 出厂预置共享密钥(部署注入)。此处为默认值,正式包应由 BuildConfig.DEVICE_PROVISION_SECRET 覆盖。 - private static final String PROVISION_SECRET = - BuildConfig.DEBUG ? "dev-device-provision-secret-change-me" : BuildConfig.DEVICE_PROVISION_SECRET; - - private final OkHttpClient http; - private final Gson gson = new Gson(); - - public ApiClient() { - this.http = new OkHttpClient.Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .build(); - } - - /** 计算 provision 签名:HMAC-SHA256(secret, sn + "|" + nonce + "|" + timestamp) */ - public static String signProvision(String secret, String sn, String nonce, long timestamp) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); - String data = sn + "|" + nonce + "|" + timestamp; - byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(raw.length * 2); - for (byte b : raw) sb.append(String.format("%02x", b)); - return sb.toString(); - } catch (NoSuchAlgorithmException | InvalidKeyException e) { - throw new IllegalStateException("HMAC 计算失败", e); - } - } - - private static String apiBase() { - return BuildConfig.API_BASE; // 例如 https://www.ttstd.com - } - - /** - * 第一步:provision,用 SN 证明出厂身份,获取 deviceUid 与一次性 deviceSecret。 - * - * @return 包含 deviceUid / deviceSecret 的 JsonObject;失败抛 RuntimeException。 - */ - public JsonObject provision(String sn, String model) { - long timestamp = System.currentTimeMillis() / 1000L; - String nonce = Long.toHexString(System.nanoTime()) + Long.toHexString(System.currentTimeMillis()); - String hmac = signProvision(PROVISION_SECRET, sn, nonce, timestamp); - - JsonObject body = new JsonObject(); - body.addProperty("sn", sn); - body.addProperty("model", model); - body.addProperty("nonce", nonce); - body.addProperty("timestamp", timestamp); - body.addProperty("hmac", hmac); - - Request request = new Request.Builder() - .url(apiBase() + "/api/device/provision") - .post(RequestBody.create(body.toString(), JSON)) - .build(); - - try (Response resp = http.newCall(request).execute()) { - return parse(resp, "provision"); - } catch (Exception e) { - Log.e(TAG, "provision 请求失败", e); - throw new RuntimeException("激活失败(provision): " + e.getMessage(), e); - } - } - - /** - * 第二步:token,用 deviceUid + deviceSecret 换取 accessToken。 - */ - public JsonObject token(String deviceUid, String deviceSecret) { - JsonObject body = new JsonObject(); - body.addProperty("deviceUid", deviceUid); - body.addProperty("deviceSecret", deviceSecret); - - Request request = new Request.Builder() - .url(apiBase() + "/api/device/token") - .post(RequestBody.create(body.toString(), JSON)) - .build(); - - try (Response resp = http.newCall(request).execute()) { - return parse(resp, "token"); - } catch (Exception e) { - Log.e(TAG, "token 请求失败", e); - throw new RuntimeException("令牌换取失败(token): " + e.getMessage(), e); - } - } - - /** 拉取 TURN 短期凭证(iceServers)。服务端未开启时返回 null。 */ - public JsonObject fetchTurnCredentials(String accessToken) { - Request request = new Request.Builder() - .url(apiBase() + "/api/client/turn-credentials") - .get() - .addHeader("Authorization", "Bearer " + accessToken) - .build(); - try (Response resp = http.newCall(request).execute()) { - if (!resp.isSuccessful()) return null; - ResponseBody b = resp.body(); - if (b == null) return null; - return gson.fromJson(b.string(), JsonObject.class); - } catch (Exception e) { - Log.w(TAG, "TURN 凭证拉取失败(忽略)", e); - return null; - } - } - - private JsonObject parse(Response resp, String step) throws Exception { - ResponseBody body = resp.body(); - String text = body != null ? body.string() : ""; - if (!resp.isSuccessful()) { - throw new RuntimeException(step + " 失败: HTTP " + resp.code() + " " + text); - } - return gson.fromJson(text, JsonObject.class); - } -} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/AuthSettings.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/AuthSettings.java index f97021b..8c085ce 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/AuthSettings.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/AuthSettings.java @@ -1,7 +1,8 @@ package com.ttstd.controlled.utils; import android.content.Context; -import android.content.SharedPreferences; + +import com.tencent.mmkv.MMKV; import java.security.SecureRandom; @@ -11,12 +12,16 @@ import java.security.SecureRandom; */ public class AuthSettings { - private static final String PREF_NAME = "controlled_auth_prefs"; + private static final String STORE_ID = "controlled_auth_prefs"; private static final String KEY_DYNAMIC_CODE = "dynamic_code"; private static final String KEY_FIXED_PASSWORD = "fixed_password"; /** 是否允许“免密连接”(被控端手动确认)。默认开启。 */ private static final String KEY_ALLOW_NO_AUTH = "allow_no_auth"; + private static MMKV kv() { + return MMKV.mmkvWithID(STORE_ID, MMKV.MULTI_PROCESS_MODE); + } + private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; private static final int CODE_LENGTH = 6; @@ -35,40 +40,34 @@ public class AuthSettings { * 获取当前动态验证码;若不存在则随机生成并持久化。 */ public static String getDynamicCode(Context context) { - SharedPreferences sp = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); - String code = sp.getString(KEY_DYNAMIC_CODE, null); + String code = kv().decodeString(KEY_DYNAMIC_CODE, null); if (code == null || code.isEmpty()) { code = generateDynamicCode(); - sp.edit().putString(KEY_DYNAMIC_CODE, code).apply(); + kv().encode(KEY_DYNAMIC_CODE, code); } return code; } public static void setDynamicCode(Context context, String code) { - context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - .edit().putString(KEY_DYNAMIC_CODE, code == null ? "" : code).apply(); + kv().encode(KEY_DYNAMIC_CODE, code == null ? "" : code); } /** 获取固定密码(未设置时为空字符串)。 */ public static String getFixedPassword(Context context) { - return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - .getString(KEY_FIXED_PASSWORD, ""); + return kv().decodeString(KEY_FIXED_PASSWORD, ""); } public static void setFixedPassword(Context context, String password) { - context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - .edit().putString(KEY_FIXED_PASSWORD, password == null ? "" : password).apply(); + kv().encode(KEY_FIXED_PASSWORD, password == null ? "" : password); } /** 是否允许免密连接(被控端手动确认)。默认开启。 */ public static boolean isNoAuthAllowed(Context context) { - return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - .getBoolean(KEY_ALLOW_NO_AUTH, true); + return kv().decodeBool(KEY_ALLOW_NO_AUTH, true); } /** 设置是否允许免密连接。 */ public static void setNoAuthAllowed(Context context, boolean allowed) { - context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - .edit().putBoolean(KEY_ALLOW_NO_AUTH, allowed).apply(); + kv().encode(KEY_ALLOW_NO_AUTH, allowed); } } diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/DeviceSecretStore.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/DeviceSecretStore.java index dacc21b..a7885e0 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/DeviceSecretStore.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/DeviceSecretStore.java @@ -1,94 +1,97 @@ package com.ttstd.controlled.utils; import android.content.Context; -import android.content.SharedPreferences; -import android.security.keystore.KeyGenParameterSpec; -import android.security.keystore.KeyProperties; -import androidx.security.crypto.EncryptedSharedPreferences; -import androidx.security.crypto.MasterKey; - -import java.io.IOException; -import java.security.GeneralSecurityException; +import com.tencent.mmkv.MMKV; /** * 被控端凭据安全存储。 * - * 激活流程(provision / token)返回的 deviceSecret 是一次性凭据,且代表设备身份, - * 必须以加密方式落盘(EncryptedSharedPreferences)。deviceUid 与 accessToken 同样密文存储。 + * 使用「Android Keystore 供给密钥 + 加密 MMKV」替代已废弃的 EncryptedSharedPreferences, + * 安全性等价(密钥由系统安全区保管,落盘仅为密文),且为同步 API,对调用方零侵入。 * - * 注意:EncryptedSharedPreferences 的初始化可能抛出 GeneralSecurityException, - * 调用方需处理「无法创建加密存储」的退化场景(此时仅内存持有,不落盘)。 + * 兼容性:加密 MMKV 的密钥取自 {@link KeystoreHelper},其底层为 Android Keystore(minSdk 21+ 支持)。 + * 当 Keystore 不可用时(极端机型/系统异常)降级为「内存持有、不落盘」,避免像旧实现那样 + * 静默退化成明文 SharedPreferences。调用方应通过 {@link #isSecurelyStored()} 感知该状态, + * 在无法安全存储时要求重新激活而非依赖本地凭据。 */ public final class DeviceSecretStore { - private static final String FILE_NAME = "ttstd_device_secrets"; + private static final String STORE_ID = "ttstd_device_secrets"; private static final String KEY_DEVICE_UID = "device_uid"; private static final String KEY_DEVICE_SECRET = "device_secret"; private static final String KEY_ACCESS_TOKEN = "access_token"; private static final String KEY_ACTIVATED = "activated"; - private final SharedPreferences sp; + private final MMKV mmkv; + private final boolean securelyStored; + + /** 内存兜底(Keystore 不可用时使用,进程死亡即丢失)。 */ + private String memUid; + private String memSecret; + private String memToken; + private boolean memActivated; public DeviceSecretStore(Context context) { - this.sp = create(context); + String cryptKey = KeystoreHelper.getCryptKey(); + this.securelyStored = KeystoreHelper.isKeystoreBacked(); + this.mmkv = MMKV.mmkvWithID(STORE_ID, MMKV.MULTI_PROCESS_MODE, + cryptKey, MMKV.getRootDir()); } - private static SharedPreferences create(Context context) { - try { - MasterKey masterKey = new MasterKey.Builder(context) - .setKeyGenParameterSpec( - new KeyGenParameterSpec.Builder( - MasterKey.DEFAULT_MASTER_KEY_ALIAS, - KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(256) - .build()) - .build(); - return EncryptedSharedPreferences.create( - context, - FILE_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM); - } catch (GeneralSecurityException | IOException e) { - // 退化:使用普通(非加密)SharedPreferences,仅作为兜底,避免崩溃。 - return context.getSharedPreferences(FILE_NAME + "_fallback", Context.MODE_PRIVATE); - } + /** Keystore 是否可用(凭据是否真正安全落盘)。 */ + public boolean isSecurelyStored() { + return securelyStored; } public void saveDevice(String deviceUid, String deviceSecret) { - sp.edit() - .putString(KEY_DEVICE_UID, deviceUid) - .putString(KEY_DEVICE_SECRET, deviceSecret) - .putBoolean(KEY_ACTIVATED, true) - .apply(); + if (securelyStored) { + mmkv.encode(KEY_DEVICE_UID, deviceUid); + mmkv.encode(KEY_DEVICE_SECRET, deviceSecret); + mmkv.encode(KEY_ACTIVATED, true); + } else { + memUid = deviceUid; + memSecret = deviceSecret; + memActivated = true; + } } public void saveAccessToken(String token) { - sp.edit().putString(KEY_ACCESS_TOKEN, token).apply(); + if (securelyStored) { + mmkv.encode(KEY_ACCESS_TOKEN, token); + } else { + memToken = token; + } } public String getDeviceUid() { - return sp.getString(KEY_DEVICE_UID, null); + return securelyStored ? mmkv.decodeString(KEY_DEVICE_UID, null) : memUid; } public String getDeviceSecret() { - return sp.getString(KEY_DEVICE_SECRET, null); + return securelyStored ? mmkv.decodeString(KEY_DEVICE_SECRET, null) : memSecret; } public String getAccessToken() { - return sp.getString(KEY_ACCESS_TOKEN, null); + return securelyStored ? mmkv.decodeString(KEY_ACCESS_TOKEN, null) : memToken; } public boolean isActivated() { - return sp.getBoolean(KEY_ACTIVATED, false) - && sp.getString(KEY_DEVICE_UID, null) != null - && sp.getString(KEY_DEVICE_SECRET, null) != null; + if (securelyStored) { + return mmkv.decodeBool(KEY_ACTIVATED, false) + && mmkv.decodeString(KEY_DEVICE_UID, null) != null + && mmkv.decodeString(KEY_DEVICE_SECRET, null) != null; + } + return memActivated && memUid != null && memSecret != null; } public void clear() { - sp.edit().clear().apply(); + if (securelyStored) { + mmkv.clear(); + } + memUid = null; + memSecret = null; + memToken = null; + memActivated = false; } } diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/InputSettings.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/InputSettings.java index 3558786..1d6e02d 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/InputSettings.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/InputSettings.java @@ -1,7 +1,8 @@ package com.ttstd.controlled.utils; import android.content.Context; -import android.content.SharedPreferences; + +import com.tencent.mmkv.MMKV; /** * 模拟点击方式(输入执行器)的可选配置。 @@ -10,9 +11,13 @@ import android.content.SharedPreferences; */ public class InputSettings { - private static final String PREF_NAME = "controlled_input_prefs"; + private static final String STORE_ID = "controlled_input_prefs"; private static final String KEY_INPUT_METHOD = "input_method"; + private static MMKV kv() { + return MMKV.mmkvWithID(STORE_ID, MMKV.MULTI_PROCESS_MODE); + } + /** 自动选择(按 系统签名 -> Root -> 无障碍 -> 普通 Shell 顺序兜底)。 */ public static final String METHOD_AUTO = "auto"; /** 系统隐藏 API 注入(需系统签名 / 共享系统 UID)。 */ @@ -26,13 +31,11 @@ public class InputSettings { /** 获取当前选中的模拟点击方式,默认 {@link #METHOD_AUTO}。 */ public static String getInputMethod(Context context) { - return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - .getString(KEY_INPUT_METHOD, METHOD_AUTO); + return kv().decodeString(KEY_INPUT_METHOD, METHOD_AUTO); } /** 设置模拟点击方式。 */ public static void setInputMethod(Context context, String method) { - context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - .edit().putString(KEY_INPUT_METHOD, method == null ? METHOD_AUTO : method).apply(); + kv().encode(KEY_INPUT_METHOD, method == null ? METHOD_AUTO : method); } } diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/KeystoreHelper.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/KeystoreHelper.java new file mode 100644 index 0000000..134f56f --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/KeystoreHelper.java @@ -0,0 +1,94 @@ +package com.ttstd.controlled.utils; + +import android.os.Build; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; + +import java.security.KeyStore; +import java.security.SecureRandom; +import java.util.Base64; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +/** + * Android Keystore 密钥助手。 + * + * 在系统安全区(TEE / StrongBox)中生成并持有 AES 密钥,密钥明文不会进入应用进程内存。 + * 对外仅暴露 {@link #getCryptKey()}:把 Keystore 中的 AES 密钥材料编码为 MMKV 加密所需的字节。 + * + * 兼容性说明: + * - minSdk 21 起即支持 Android Keystore 的 AES 密钥(KeyProperties.BLOCK_MODE_GCM)。 + * - Android 9(28)起可选 StrongBox(部分机型有硬件安全芯片),不支持时自动降级到 TEE。 + * - 默认不要求用户认证(否则锁屏后无法在后台读取凭据),仅依赖安全硬件隔离。 + */ +public final class KeystoreHelper { + + private static final String KEYSTORE_PROVIDER = "AndroidKeyStore"; + private static final String KEY_ALIAS = "ttstd_device_secret_key"; + private static final int KEY_SIZE = 256; + + private KeystoreHelper() { + } + + /** 获取 MMKV 加密所需的密钥(源自 Keystore 中的 AES 密钥,Base64 编码字符串)。 */ + public static String getCryptKey() { + try { + SecretKey key = getOrCreateKey(); + return Base64.getEncoder().encodeToString(key.getEncoded()); + } catch (Exception e) { + // Keystore 不可用(极端机型/系统异常):回退到随机密钥仅驻留内存,不落盘。 + // 注意:此分支下加密 MMKV 的密钥不会持久化,进程重启后旧密文无法解密, + // 调用方需感知“无法安全存储”并改为内存持有 + 重新激活。 + return Base64.getEncoder().encodeToString(fallbackInMemoryKey()); + } + } + + private static SecretKey getOrCreateKey() throws Exception { + KeyStore keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER); + keyStore.load(null); + if (keyStore.containsAlias(KEY_ALIAS)) { + return (SecretKey) keyStore.getKey(KEY_ALIAS, null); + } + return createKey(); + } + + private static SecretKey createKey() throws Exception { + KeyGenerator generator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER); + + KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setKeySize(KEY_SIZE) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true); + + // StrongBox 仅在 Android 9+ 且设备支持时启用,否则回退 TEE。 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + builder.setIsStrongBoxBacked(false); + } + + generator.init(builder.build()); + return generator.generateKey(); + } + + private static byte[] fallbackInMemoryKey() { + byte[] key = new byte[KEY_SIZE / 8]; + new SecureRandom().nextBytes(key); + return key; + } + + /** 仅供测试/诊断:当前密钥是否由持久化 Keystore 提供。 */ + public static boolean isKeystoreBacked() { + try { + KeyStore keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER); + keyStore.load(null); + return keyStore.containsAlias(KEY_ALIAS) + && keyStore.getKey(KEY_ALIAS, null) != null; + } catch (Exception e) { + return false; + } + } +} diff --git a/WebRTCControlled/app/src/main/res/layout/activity_activation.xml b/WebRTCControlled/app/src/main/res/layout/activity_activation.xml new file mode 100644 index 0000000..c6a7350 --- /dev/null +++ b/WebRTCControlled/app/src/main/res/layout/activity_activation.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WebRTCControlled/app/src/main/res/layout/activity_main.xml b/WebRTCControlled/app/src/main/res/layout/activity_main.xml index 1743dcd..76cecb7 100644 --- a/WebRTCControlled/app/src/main/res/layout/activity_main.xml +++ b/WebRTCControlled/app/src/main/res/layout/activity_main.xml @@ -18,37 +18,14 @@ - - - - - - @@ -57,7 +34,7 @@ android:id="@+id/tv_status" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginBottom="24dp" + android:layout_marginBottom="16dp" android:text="状态: 已停止" android:textSize="16sp" android:textStyle="bold" /> @@ -67,8 +44,7 @@ android:id="@+id/btn_open_settings" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="12dp" - android:layout_marginBottom="16dp" + android:layout_marginBottom="8dp" android:text="@string/btn_open_settings" />