feat(android): add device activation flow and update dependencies

重构项目架构,引入启动页(SplashActivity)检查激活状态,未激活设备引导至激活页(ActivationActivity)完成provision+token流程。集成Retrofit+RxJava3网络层、Room数据库、Lifecycle组件、MMKV等依赖,升级OkHttp/Gson版本,并将构建配置从旧项目全面迁移至新项目结构。
This commit is contained in:
TongTongStudio
2026-08-03 00:25:51 +08:00
parent 61398ff4ff
commit ded6fe5ab7
105 changed files with 5363 additions and 1503 deletions

View File

@@ -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'
// ProtobufDataChannel 控制指令二进制)
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'
// ProtobufDataChannel 控制指令二进制)
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'
}

View File

@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.INJECT_EVENTS" />
<application
android:name=".base.BaseApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -16,8 +17,9 @@
android:supportsRtl="true"
android:theme="@style/Theme.MaterialComponents.DayNight">
<!-- 启动页:检查激活状态后分发到主页或激活页 -->
<activity
android:name=".activity.main.MainActivity"
android:name=".activity.splash.SplashActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -25,6 +27,16 @@
</intent-filter>
</activity>
<!-- 激活页:设备未激活时提示并支持刷新重试 -->
<activity
android:name=".activity.activation.ActivationActivity"
android:exported="false"
android:label="@string/activation_title" />
<activity
android:name=".activity.main.MainActivity"
android:exported="false" />
<activity
android:name=".activity.settings.SettingsActivity"
android:exported="false"
@@ -43,8 +55,8 @@
<!-- 键盘/输入模拟无障碍服务(无需系统签名/root用于模拟全局按键与手势 -->
<service
android:name=".accessibility.KeyboardAccessibilityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="false">
android:exported="false"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>

View File

@@ -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<ActivationViewModel, ActivityActivationBinding> {
@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();
}
}

View File

@@ -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<Boolean> loading = new MutableLiveData<>(false);
/** 激活成功事件。 */
private final MutableLiveData<Boolean> success = new MutableLiveData<>();
/** 激活失败原因。 */
private final MutableLiveData<String> error = new MutableLiveData<>();
private DeviceRepository repository;
public LiveData<Boolean> getLoading() {
return loading;
}
public LiveData<Boolean> getSuccess() {
return success;
}
public LiveData<String> 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);
});
}
}

View File

@@ -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<MainViewModel, ActivityMainBi
@Override
protected void initView() {
// 默认服务器地址
binding.etServerUrl.setText("wss://www.ttstd.com/signal");
// 设备ID 由服务端激活流程下发deviceUid用户无需填写仅作只读展示。
binding.etDeviceId.setEnabled(false);
binding.etDeviceId.setText("(激活后由服务端下发)");
@@ -196,7 +190,7 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
Intent serviceIntent = new Intent(this, ScreenCaptureService.class);
serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_CODE, resultCode);
serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_DATA, data);
serviceIntent.putExtra(ScreenCaptureService.EXTRA_SERVER_URL, binding.etServerUrl.getText().toString());
serviceIntent.putExtra(ScreenCaptureService.EXTRA_SERVER_URL, "ws://192.168.5.224:8080/ws/signal");
// 设备ID 不再由外部传入service 内部完成 provision/token 激活后由服务端下发 deviceUid。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -329,7 +323,6 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
isServiceRunning = running;
binding.btnStart.setEnabled(!running);
binding.btnStop.setEnabled(running);
binding.etServerUrl.setEnabled(!running);
// 设备ID 输入框始终只读(由服务端激活下发)。
binding.etDeviceId.setEnabled(false);
binding.spinnerResolution.setEnabled(running && isBound);

View File

@@ -1,6 +1,6 @@
package com.ttstd.controlled.activity.main;
import com.ttstd.controlled.base.BaseViewModel;
import com.ttstd.controlled.base.mvvm.BaseViewModel;
public class MainViewModel extends BaseViewModel {
}

View File

@@ -5,7 +5,7 @@ import android.widget.Spinner;
import android.widget.Toast;
import com.ttstd.controlled.R;
import com.ttstd.controlled.base.BaseMvvmActivity;
import com.ttstd.controlled.base.mvvm.BaseMvvmActivity;
import com.ttstd.controlled.databinding.ActivitySettingsBinding;
import com.ttstd.controlled.utils.AuthSettings;
import com.ttstd.controlled.utils.InputSettings;

View File

@@ -1,6 +1,6 @@
package com.ttstd.controlled.activity.settings;
import com.ttstd.controlled.base.BaseViewModel;
import com.ttstd.controlled.base.mvvm.BaseViewModel;
public class SettingsViewModel extends BaseViewModel {
}

View File

@@ -0,0 +1,54 @@
package com.ttstd.controlled.activity.splash;
import android.content.Intent;
import com.ttstd.controlled.R;
import com.ttstd.controlled.activity.activation.ActivationActivity;
import com.ttstd.controlled.activity.main.MainActivity;
import com.ttstd.controlled.base.mvvm.BaseMvvmActivity;
import com.ttstd.controlled.databinding.ActivitySplashBinding;
/**
* 启动页。
*
* 作为应用入口:检查本地设备激活状态,
* - 已激活:直接进入 {@link MainActivity}
* - 未激活:进入 {@link ActivationActivity} 提示并引导激活。
*/
public class SplashActivity extends BaseMvvmActivity<SplashViewModel, ActivitySplashBinding> {
/** 启动页最短停留时间,避免闪屏一闪而过。 */
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直接忽略。
}
}

View File

@@ -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<Boolean> activated = new MutableLiveData<>();
private DeviceRepository repository;
public LiveData<Boolean> getActivated() {
return activated;
}
public void check(Context context) {
if (repository == null) {
repository = new DeviceRepository(context);
}
// 本地凭据判断为纯内存/SP 读取,无需网络请求。
activated.setValue(repository.isActivated());
}
}

View File

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

View File

@@ -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<ProvisionResponse> provision(@Body ProvisionRequest request);
/**
* 激活第二步:用 deviceUid + deviceSecret 换取 accessTokenWebSocket Bearer 握手使用)。
*/
@POST("/api/device/token")
Single<TokenResponse> token(@Body TokenRequest request);
/**
* 拉取 TURN 短期凭证iceServers。服务端未开启时可能返回错误调用方需自行降级。
*/
@GET("/api/client/turn-credentials")
Single<JsonObject> turnCredentials(@Header("Authorization") String bearerToken);
}

View File

@@ -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<String> 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<String> reactivate() {
return Single.defer(() -> {
store.clear();
return provision().flatMap(resp -> exchangeToken(resp.getDeviceUid(), resp.getDeviceSecret()));
}).subscribeOn(Schedulers.io());
}
/** 第一步:用 SN + HMAC 证明出厂身份,成功后立即加密落盘。 */
private Single<ProvisionResponse> 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<String> 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<JsonObject> fetchTurnCredentials(String accessToken) {
if (TextUtils.isEmpty(accessToken)) {
return Single.just(new JsonObject());
}
return api.turnCredentials("Bearer " + accessToken)
.onErrorReturnItem(new JsonObject())
.subscribeOn(Schedulers.io());
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
* 在 onClearedService 销毁)时自动取消,不再使用 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<ActivityEvent> lifecycleSubject;
private DeviceRepository repository;
// 设置Service生命周期Subject
public void setLifecycleSubject(BehaviorSubject<ActivityEvent> lifecycleSubject) {
this.lifecycleSubject = lifecycleSubject;
}
/** 激活成功,携带 accessToken。 */
private final MutableLiveData<String> activateSuccess = new MutableLiveData<>();
/** 激活失败,携带原因。 */
private final MutableLiveData<String> activateFailed = new MutableLiveData<>();
/** 令牌刷新成功,携带新的 accessToken。 */
private final MutableLiveData<String> tokenRefreshed = new MutableLiveData<>();
/** 令牌刷新失败,携带原因。 */
private final MutableLiveData<String> tokenRefreshFailed = new MutableLiveData<>();
// 绑定请求到Service销毁事件自动取消请求
private <T> LifecycleTransformer<T> 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<String> getActivateSuccess() {
return activateSuccess;
}
public LiveData<String> getActivateFailed() {
return activateFailed;
}
public void uploadLocation(SnLocationReq locationReq) {
Log.e(TAG, "uploadLocation: ");
OkHttpManager.getInstance().getUploadLocationObservable(locationReq)
.compose(bindToLifecycle())
.subscribe(new BaseObserver<BaseResponse>() {
@Override
public void onSuccess(BaseResponse baseResponse) {
Log.e("uploadLocation", "onSuccess: " + baseResponse);
}
public LiveData<String> getTokenRefreshed() {
return tokenRefreshed;
}
@Override
public void onFailure(Throwable e) {
Log.e("uploadLocation", "onFailure: " + e.getMessage());
}
public LiveData<String> 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;
}
}

View File

@@ -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;
/** 当前 accessTokenBearer 握手用),失效后重新换取。 */
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 实现了 LifecycleOwnerobserve 会随 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();
}
/**

View File

@@ -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 与一次性 deviceSecretdeviceSecret 仅返回这一次,需立即安全落盘)。
* - 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);
}
}

View File

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

View File

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

View File

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

View File

@@ -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 928起可选 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;
}
}
}

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp">
<ImageView
android:id="@+id/ivIcon"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_marginTop="72dp"
android:contentDescription="@string/activation_title"
android:src="@mipmap/ic_launcher"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/activation_title"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/ivIcon" />
<TextView
android:id="@+id/tvMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:text="@string/activation_hint"
android:textSize="15sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvTitle" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvMessage" />
<TextView
android:id="@+id/tvDeviceInfo"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:alpha="0.7"
android:gravity="center"
android:textSize="13sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/progressBar" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnRetry"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/activation_retry"
app:layout_constraintBottom_toTopOf="@+id/btnSettings"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSettings"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/btn_open_settings"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -18,37 +18,14 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="WebRTC 被控端"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="信令服务器地址:"
android:text="设备ID:"
android:textSize="14sp" />
<EditText
android:id="@+id/et_server_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:hint="wss://www.ttstd.com/signal"
android:inputType="textUri" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="设备ID由服务端激活下发无需填写:"
android:textSize="14sp" />
<EditText
android:id="@+id/et_device_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:hint="激活后自动填充"
android:layout_marginBottom="16dp"
android:inputType="text"
android:enabled="false"
android:text="" />
@@ -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" />
<Button
@@ -82,7 +58,7 @@
android:id="@+id/btn_stop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:layout_marginBottom="8dp"
android:enabled="false"
android:text="停止屏幕共享" />

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/colorBackground">
<ImageView
android:id="@+id/ivLogo"
android:layout_width="96dp"
android:layout_height="96dp"
android:contentDescription="@string/app_name"
android:src="@mipmap/ic_launcher"
app:layout_constraintBottom_toTopOf="@+id/tvAppName"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/tvAppName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/app_name"
android:textSize="22sp"
android:textStyle="bold"
app:layout_constraintBottom_toTopOf="@+id/progressBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/ivLogo" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvAppName" />
<TextView
android:id="@+id/tvStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="48dp"
android:text="@string/splash_checking"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -25,6 +25,19 @@
<string name="allow_no_auth_on">已允许免密连接</string>
<string name="allow_no_auth_off">已关闭免密连接</string>
<!-- 启动页 -->
<string name="splash_checking">正在检查设备激活状态…</string>
<string name="splash_activating">正在激活设备…</string>
<!-- 激活页 -->
<string name="activation_title">设备未激活</string>
<string name="activation_hint">本设备尚未完成激活,无法连接远程控制服务。\n请确认网络连接正常后点击下方「重新激活」按钮重试。</string>
<string name="activation_activating">正在激活,请稍候…</string>
<string name="activation_failed">激活失败:%s</string>
<string name="activation_success">激活成功</string>
<string name="activation_retry">重新激活</string>
<string name="activation_device_info">设备型号:%1$s\n序列号%2$s</string>
<!-- 设置页面 -->
<string name="settings_title">安全与输入设置</string>
<string name="btn_open_settings">安全与输入设置</string>

View File

@@ -1,6 +1,11 @@
// Top-level build file
buildscript {
ext.kotlin_version = '2.4.0'
}
plugins {
id 'com.android.application' version '8.1.4' apply false
id 'com.android.application' version '8.13.2' apply false
id 'com.google.protobuf' version '0.9.4' apply false
id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false
id 'org.jetbrains.kotlin.kapt' version "$kotlin_version" apply false
}
apply from: "config.gradle"