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:
@@ -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'
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
@@ -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,直接忽略。
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 换取 accessToken(WebSocket 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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 + "/";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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="停止屏幕共享" />
|
||||
|
||||
|
||||
59
WebRTCControlled/app/src/main/res/layout/activity_splash.xml
Normal file
59
WebRTCControlled/app/src/main/res/layout/activity_splash.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user