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,9 @@ 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\""
|
||||
buildConfigField "String", "WS_URL", "\"ws://192.168.5.224:8080/ws/signal\""
|
||||
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -31,6 +35,10 @@ android {
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
dataBinding true
|
||||
buildConfig true
|
||||
@@ -107,15 +115,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,17 +142,56 @@ 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'
|
||||
//生命周期管理
|
||||
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'
|
||||
|
||||
// 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(保存 accessToken / refreshToken)
|
||||
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
||||
implementation 'com.tencent:mmkv-static:2.4.0'
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
|
||||
<application
|
||||
android:name=".base.BaseApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -33,7 +34,7 @@
|
||||
android:theme="@style/Theme.MaterialComponents.DayNight">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:name=".activity.splash.SplashActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
@@ -42,6 +43,17 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".activity.login.LoginActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".activity.main.MainActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ttstd.controller.activity.login;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.ttstd.controller.R;
|
||||
import com.ttstd.controller.activity.main.MainActivity;
|
||||
import com.ttstd.controller.base.mvvm.BaseMvvmActivity;
|
||||
import com.ttstd.controller.databinding.ActivityLoginBinding;
|
||||
|
||||
/**
|
||||
* 登录页:用户名 + 密码登录,成功后进入 {@link MainActivity}。
|
||||
*/
|
||||
public class LoginActivity extends BaseMvvmActivity<LoginViewModel, ActivityLoginBinding> {
|
||||
|
||||
@Override
|
||||
protected int getLayoutId() {
|
||||
return R.layout.activity_login;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initView() {
|
||||
String lastUsername = viewModel.lastUsername();
|
||||
if (!TextUtils.isEmpty(lastUsername)) {
|
||||
binding.etUsername.setText(lastUsername);
|
||||
binding.etPassword.requestFocus();
|
||||
}
|
||||
|
||||
binding.btnLogin.setOnClickListener(v -> doLogin());
|
||||
|
||||
binding.etPassword.setOnEditorActionListener((v, actionId, event) -> {
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
doLogin();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initData() {
|
||||
viewModel.getLoading().observe(this, loading -> {
|
||||
boolean busy = Boolean.TRUE.equals(loading);
|
||||
binding.pbLoading.setVisibility(busy ? View.VISIBLE : View.GONE);
|
||||
binding.btnLogin.setEnabled(!busy);
|
||||
binding.btnLogin.setText(busy ? R.string.login_in_progress : R.string.login_action);
|
||||
});
|
||||
|
||||
viewModel.getErrorMessage().observe(this, msg -> {
|
||||
if (!TextUtils.isEmpty(msg)) {
|
||||
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
|
||||
viewModel.getLoginSuccess().observe(this, success -> {
|
||||
if (Boolean.TRUE.equals(success)) {
|
||||
Toast.makeText(this, R.string.login_success, Toast.LENGTH_SHORT).show();
|
||||
startActivity(new Intent(this, MainActivity.class));
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void doLogin() {
|
||||
String username = binding.etUsername.getText() == null
|
||||
? "" : binding.etUsername.getText().toString();
|
||||
String password = binding.etPassword.getText() == null
|
||||
? "" : binding.etPassword.getText().toString();
|
||||
viewModel.login(username, password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.ttstd.controller.activity.login;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import com.ttstd.controller.base.mvvm.BaseViewModel;
|
||||
import com.ttstd.controller.network.AuthRepository;
|
||||
import com.ttstd.controller.utils.TokenStore;
|
||||
|
||||
import retrofit2.HttpException;
|
||||
|
||||
/**
|
||||
* 登录页 ViewModel:用户名密码登录。
|
||||
*
|
||||
* <p>网络请求通过 Retrofit 返回 Single,由基类 CompositeDisposable 托管,
|
||||
* 页面销毁自动取消订阅。
|
||||
*/
|
||||
public class LoginViewModel extends BaseViewModel {
|
||||
|
||||
private final AuthRepository repository = new AuthRepository();
|
||||
|
||||
private final MutableLiveData<Boolean> loading = new MutableLiveData<>(false);
|
||||
private final MutableLiveData<Boolean> loginSuccess = new MutableLiveData<>();
|
||||
private final MutableLiveData<String> errorMessage = new MutableLiveData<>();
|
||||
|
||||
public LiveData<Boolean> getLoading() {
|
||||
return loading;
|
||||
}
|
||||
|
||||
public LiveData<Boolean> getLoginSuccess() {
|
||||
return loginSuccess;
|
||||
}
|
||||
|
||||
public LiveData<String> getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
/** 回显上次登录的用户名。 */
|
||||
public String lastUsername() {
|
||||
return TokenStore.get().getUsername();
|
||||
}
|
||||
|
||||
public void login(String username, String password) {
|
||||
if (TextUtils.isEmpty(username)) {
|
||||
errorMessage.setValue("请输入用户名");
|
||||
return;
|
||||
}
|
||||
if (TextUtils.isEmpty(password)) {
|
||||
errorMessage.setValue("请输入密码");
|
||||
return;
|
||||
}
|
||||
if (Boolean.TRUE.equals(loading.getValue())) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.setValue(true);
|
||||
execute(
|
||||
repository.login(username.trim(), password),
|
||||
token -> {
|
||||
loading.setValue(false);
|
||||
loginSuccess.setValue(true);
|
||||
},
|
||||
error -> {
|
||||
loading.setValue(false);
|
||||
errorMessage.setValue(parseError(error));
|
||||
});
|
||||
}
|
||||
|
||||
private String parseError(Throwable error) {
|
||||
if (error instanceof HttpException) {
|
||||
int code = ((HttpException) error).code();
|
||||
if (code == 401 || code == 400) {
|
||||
return "用户名或密码错误";
|
||||
}
|
||||
if (code == 429) {
|
||||
return "尝试过于频繁,请稍后再试";
|
||||
}
|
||||
return "登录失败(HTTP " + code + ")";
|
||||
}
|
||||
String msg = error.getMessage();
|
||||
return TextUtils.isEmpty(msg) ? "网络异常,请检查服务器地址" : msg;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,105 @@
|
||||
package com.ttstd.controlled.activity.main;
|
||||
package com.ttstd.controller.activity.main;
|
||||
|
||||
import com.ttstd.controlled.base.mvvm.BaseViewModel;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import com.ttstd.controller.base.mvvm.BaseViewModel;
|
||||
import com.ttstd.controller.network.AuthRepository;
|
||||
import com.ttstd.controller.network.model.BindingItem;
|
||||
import com.ttstd.controller.utils.TokenStore;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* 主页 ViewModel:负责令牌保活与绑定设备列表拉取。
|
||||
*
|
||||
* <p>所有网络请求均走 Retrofit + RxJava3,由基类 CompositeDisposable 托管,
|
||||
* Activity 销毁时自动取消,替代原先的 {@code new Thread} + {@code runOnUiThread} 写法。
|
||||
*/
|
||||
public class MainViewModel extends BaseViewModel {
|
||||
|
||||
private final AuthRepository repository = new AuthRepository();
|
||||
|
||||
/** 刷新单飞标记,避免并发重复刷新令牌。 */
|
||||
private final AtomicBoolean refreshInFlight = new AtomicBoolean(false);
|
||||
|
||||
/** 可用的 accessToken,用于建立 WebSocket。 */
|
||||
private final MutableLiveData<String> accessToken = new MutableLiveData<>();
|
||||
|
||||
/** 令牌重新可用(4001 过期后刷新成功),需要重连信令。 */
|
||||
private final MutableLiveData<String> reconnectToken = new MutableLiveData<>();
|
||||
|
||||
/** 认证失败,需要重新登录。 */
|
||||
private final MutableLiveData<String> authFailed = new MutableLiveData<>();
|
||||
|
||||
/** 已绑定设备列表。 */
|
||||
private final MutableLiveData<List<BindingItem>> bindings = new MutableLiveData<>();
|
||||
|
||||
public LiveData<String> getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
public LiveData<String> getReconnectToken() {
|
||||
return reconnectToken;
|
||||
}
|
||||
|
||||
public LiveData<String> getAuthFailed() {
|
||||
return authFailed;
|
||||
}
|
||||
|
||||
public LiveData<List<BindingItem>> getBindings() {
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 accessToken 有效:先校验,失效则自动刷新,结果通过
|
||||
* {@link #getAccessToken()} 回调到主线程。
|
||||
*/
|
||||
public void prepareAccessToken() {
|
||||
execute(
|
||||
repository.checkLoginState(),
|
||||
valid -> {
|
||||
if (Boolean.TRUE.equals(valid)) {
|
||||
accessToken.setValue(TokenStore.get().getAccessToken());
|
||||
} else {
|
||||
authFailed.setValue("登录已失效,请重新登录");
|
||||
}
|
||||
},
|
||||
error -> authFailed.setValue("认证失败:" + error.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 收到 4001(令牌过期):单飞刷新后通知重连。
|
||||
*/
|
||||
public void refreshTokenAndReconnect() {
|
||||
if (!refreshInFlight.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
execute(
|
||||
repository.refresh(),
|
||||
token -> {
|
||||
refreshInFlight.set(false);
|
||||
reconnectToken.setValue(token.getAccessToken());
|
||||
},
|
||||
error -> {
|
||||
refreshInFlight.set(false);
|
||||
authFailed.setValue("令牌刷新失败,请重新登录");
|
||||
});
|
||||
}
|
||||
|
||||
/** 拉取当前用户已绑定的被控端设备列表。 */
|
||||
public void loadBindings() {
|
||||
execute(
|
||||
repository.bindings(),
|
||||
response -> bindings.setValue(response.getBindings()),
|
||||
error -> {
|
||||
// 列表拉取失败不阻断主流程,保持静默
|
||||
});
|
||||
}
|
||||
|
||||
/** 退出登录:清空本地令牌。 */
|
||||
public void logout() {
|
||||
repository.logout();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ttstd.controller.activity.splash;
|
||||
|
||||
import android.content.Intent;
|
||||
|
||||
import com.ttstd.controller.R;
|
||||
import com.ttstd.controller.activity.login.LoginActivity;
|
||||
import com.ttstd.controller.activity.main.MainActivity;
|
||||
import com.ttstd.controller.base.mvvm.BaseMvvmActivity;
|
||||
import com.ttstd.controller.databinding.ActivitySplashBinding;
|
||||
|
||||
/**
|
||||
* 启动页。
|
||||
*
|
||||
* <p>校验本地令牌:已登录直接进入 {@link MainActivity},否则进入 {@link LoginActivity}。
|
||||
*/
|
||||
public class SplashActivity extends BaseMvvmActivity<SplashViewModel, ActivitySplashBinding> {
|
||||
|
||||
@Override
|
||||
protected int getLayoutId() {
|
||||
return R.layout.activity_splash;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initView() {
|
||||
// 启动页无交互
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initData() {
|
||||
viewModel.getLoggedIn().observe(this, loggedIn -> {
|
||||
if (Boolean.TRUE.equals(loggedIn)) {
|
||||
startActivity(new Intent(this, MainActivity.class));
|
||||
} else {
|
||||
startActivity(new Intent(this, LoginActivity.class));
|
||||
}
|
||||
finish();
|
||||
});
|
||||
|
||||
viewModel.checkLoginState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
// 启动检查期间屏蔽返回,避免路由状态错乱
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ttstd.controller.activity.splash;
|
||||
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import com.ttstd.controller.base.mvvm.BaseViewModel;
|
||||
import com.ttstd.controller.network.AuthRepository;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 启动页 ViewModel:校验本地登录态,决定跳转登录页还是主页。
|
||||
*/
|
||||
public class SplashViewModel extends BaseViewModel {
|
||||
|
||||
/** 最短停留时长,避免闪屏一闪而过。 */
|
||||
private static final long MIN_SPLASH_MILLIS = 800L;
|
||||
|
||||
private final AuthRepository repository = new AuthRepository();
|
||||
|
||||
/** true=已登录进主页,false=去登录页。 */
|
||||
private final MutableLiveData<Boolean> loggedIn = new MutableLiveData<>();
|
||||
|
||||
public LiveData<Boolean> getLoggedIn() {
|
||||
return loggedIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验登录态。请求由 BaseViewModel 的 CompositeDisposable 托管,
|
||||
* 页面销毁时自动取消,不会产生野线程回调。
|
||||
*/
|
||||
public void checkLoginState() {
|
||||
execute(
|
||||
Single.zip(
|
||||
repository.checkLoginState(),
|
||||
Single.timer(MIN_SPLASH_MILLIS, TimeUnit.MILLISECONDS, Schedulers.io()),
|
||||
(valid, ignored) -> valid),
|
||||
loggedIn::setValue,
|
||||
error -> loggedIn.setValue(false));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import android.util.Log;
|
||||
import androidx.multidex.MultiDex;
|
||||
|
||||
import com.tencent.mmkv.MMKV;
|
||||
import com.ttstd.controller.utils.TokenStore;
|
||||
|
||||
public class BaseApplication extends Application {
|
||||
private static final String TAG = "BaseApplication";
|
||||
@@ -56,6 +57,9 @@ public class BaseApplication extends Application {
|
||||
Log.e(TAG, "init: ");
|
||||
String rootDir = MMKV.initialize(this);
|
||||
Log.e(TAG, "mmkv root: " + rootDir);
|
||||
|
||||
// 令牌安全存储初始化,供 OkHttp 拦截器与仓库层全局访问
|
||||
TokenStore.init(this);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import androidx.lifecycle.LifecycleRegistry;
|
||||
import androidx.lifecycle.ViewModelStore;
|
||||
import androidx.lifecycle.ViewModelStoreOwner;
|
||||
|
||||
import com.ttstd.controlled.base.rx.BaseRxService;
|
||||
import com.ttstd.controller.base.rx.BaseRxService;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ttstd.controller.config;
|
||||
|
||||
/** 主控端全局配置。 */
|
||||
public final class CommonConfig {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ttstd.controller.network;
|
||||
|
||||
import com.ttstd.controller.network.model.LoginRequest;
|
||||
import com.ttstd.controller.network.model.RefreshRequest;
|
||||
import com.ttstd.controller.network.model.TokenResponse;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import retrofit2.http.Body;
|
||||
import retrofit2.http.POST;
|
||||
|
||||
/**
|
||||
* 用户端认证接口,对应服务端 {@code /api/auth}(用户端,非设备端 {@code /api/device})。
|
||||
*/
|
||||
public interface AuthApi {
|
||||
|
||||
/** 用户名密码登录,成功后下发 access / refresh 双令牌。 */
|
||||
@POST("api/auth/login")
|
||||
Single<TokenResponse> login(@Body LoginRequest request);
|
||||
|
||||
/** 使用 refreshToken 换取新的 access 令牌。 */
|
||||
@POST("api/auth/refresh")
|
||||
Single<TokenResponse> refresh(@Body RefreshRequest request);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ttstd.controller.network;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.ttstd.controller.utils.TokenStore;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import okhttp3.Interceptor;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* 统一注入 Bearer 令牌的拦截器。
|
||||
*
|
||||
* <p>登录与刷新接口本身不需要(也不能)携带旧令牌,因此在此跳过。
|
||||
*/
|
||||
public class AuthInterceptor implements Interceptor {
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Response intercept(@NonNull Chain chain) throws IOException {
|
||||
Request original = chain.request();
|
||||
String path = original.url().encodedPath();
|
||||
|
||||
if (path.contains("/api/auth/login") || path.contains("/api/auth/refresh")) {
|
||||
return chain.proceed(original);
|
||||
}
|
||||
|
||||
String token = TokenStore.get().getAccessToken();
|
||||
if (token == null || token.isEmpty()) {
|
||||
return chain.proceed(original);
|
||||
}
|
||||
|
||||
Request authorized = original.newBuilder()
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.build();
|
||||
return chain.proceed(authorized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ttstd.controller.network;
|
||||
|
||||
import com.ttstd.controller.network.model.BindingsResponse;
|
||||
import com.ttstd.controller.network.model.LoginRequest;
|
||||
import com.ttstd.controller.network.model.RefreshRequest;
|
||||
import com.ttstd.controller.network.model.TokenResponse;
|
||||
import com.ttstd.controller.network.model.VerifyResponse;
|
||||
import com.ttstd.controller.utils.TokenStore;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
/**
|
||||
* 用户端认证 / 会话仓库。
|
||||
*
|
||||
* <p>对外统一返回 {@code Single} 冷流并已切到 IO 线程,由 ViewModel 的
|
||||
* CompositeDisposable 订阅,随生命周期自动取消。
|
||||
*/
|
||||
public class AuthRepository {
|
||||
|
||||
private final AuthApi authApi;
|
||||
private final ClientApi clientApi;
|
||||
|
||||
public AuthRepository() {
|
||||
this.authApi = RetrofitClient.authApi();
|
||||
this.clientApi = RetrofitClient.clientApi();
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户名密码登录,成功后自动持久化令牌。
|
||||
*/
|
||||
public Single<TokenResponse> login(String username, String password) {
|
||||
return authApi.login(new LoginRequest(username, password))
|
||||
.doOnSuccess(token -> TokenStore.get().save(
|
||||
token.getAccessToken(),
|
||||
token.getRefreshToken(),
|
||||
username))
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用本地 refreshToken 续期,成功后覆盖保存(服务端会轮换 refreshToken)。
|
||||
*/
|
||||
public Single<TokenResponse> refresh() {
|
||||
String refreshToken = TokenStore.get().getRefreshToken();
|
||||
if (refreshToken == null || refreshToken.isEmpty()) {
|
||||
return Single.error(new IllegalStateException("本地无刷新令牌"));
|
||||
}
|
||||
return authApi.refresh(new RefreshRequest(refreshToken))
|
||||
.doOnSuccess(token -> {
|
||||
TokenStore.get().saveAccessToken(token.getAccessToken());
|
||||
TokenStore.get().saveRefreshToken(token.getRefreshToken());
|
||||
})
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动页登录态检查:先校验 access 令牌,失效则用 refresh 续期后再校验一次。
|
||||
*
|
||||
* @return true 表示已登录可直接进入主页
|
||||
*/
|
||||
public Single<Boolean> checkLoginState() {
|
||||
if (!TokenStore.get().hasTokens()) {
|
||||
return Single.just(false);
|
||||
}
|
||||
return clientApi.verify()
|
||||
.map(VerifyResponse::isValid)
|
||||
.onErrorResumeNext(error -> refresh()
|
||||
.flatMap(token -> clientApi.verify())
|
||||
.map(VerifyResponse::isValid)
|
||||
.onErrorReturnItem(false))
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
/** 查询当前用户已绑定的被控端设备列表。 */
|
||||
public Single<BindingsResponse> bindings() {
|
||||
return clientApi.bindings().subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
/** 退出登录:清空本地令牌。 */
|
||||
public void logout() {
|
||||
TokenStore.get().clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ttstd.controller.network;
|
||||
|
||||
import com.ttstd.controller.network.model.BindingsResponse;
|
||||
import com.ttstd.controller.network.model.VerifyResponse;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import retrofit2.http.GET;
|
||||
|
||||
/**
|
||||
* 用户端业务接口,对应服务端 {@code /api/client}。
|
||||
*
|
||||
* <p>令牌由 {@link AuthInterceptor} 统一注入 Authorization 头,此处无需声明。
|
||||
*/
|
||||
public interface ClientApi {
|
||||
|
||||
/** 校验当前令牌是否有效,用于启动页判断登录态。 */
|
||||
@GET("api/client/verify")
|
||||
Single<VerifyResponse> verify();
|
||||
|
||||
/** 查询当前用户已绑定的被控端设备列表。 */
|
||||
@GET("api/client/bindings")
|
||||
Single<BindingsResponse> bindings();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ttstd.controller.network;
|
||||
|
||||
import com.ttstd.controller.BuildConfig;
|
||||
import com.ttstd.controller.config.CommonConfig;
|
||||
|
||||
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 单例。
|
||||
*
|
||||
* <p>统一注入 {@link AuthInterceptor} 携带 Bearer 令牌,并通过 RxJava3 适配器
|
||||
* 返回 {@code Single} 冷流,便于在 ViewModel 中配合 CompositeDisposable 管理生命周期。
|
||||
*/
|
||||
public final class RetrofitClient {
|
||||
/** 网络超时(秒)。 */
|
||||
public static final long TIMEOUT_SECONDS = 15L;
|
||||
|
||||
private static volatile Retrofit retrofit;
|
||||
private static volatile AuthApi authApi;
|
||||
private static volatile ClientApi clientApi;
|
||||
|
||||
private RetrofitClient() {
|
||||
}
|
||||
|
||||
private static Retrofit retrofit() {
|
||||
if (retrofit == null) {
|
||||
synchronized (RetrofitClient.class) {
|
||||
if (retrofit == null) {
|
||||
retrofit = build();
|
||||
}
|
||||
}
|
||||
}
|
||||
return retrofit;
|
||||
}
|
||||
|
||||
private static Retrofit build() {
|
||||
HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
|
||||
logging.setLevel(HttpLoggingInterceptor.Level.BODY);
|
||||
|
||||
OkHttpClient okHttpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.addInterceptor(new AuthInterceptor())
|
||||
.addInterceptor(logging)
|
||||
.build();
|
||||
|
||||
return new Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
|
||||
.build();
|
||||
}
|
||||
|
||||
public static AuthApi authApi() {
|
||||
if (authApi == null) {
|
||||
synchronized (RetrofitClient.class) {
|
||||
if (authApi == null) {
|
||||
authApi = retrofit().create(AuthApi.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
return authApi;
|
||||
}
|
||||
|
||||
public static ClientApi clientApi() {
|
||||
if (clientApi == null) {
|
||||
synchronized (RetrofitClient.class) {
|
||||
if (clientApi == null) {
|
||||
clientApi = retrofit().create(ClientApi.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
return clientApi;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ttstd.controller.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 绑定关系条目,对应服务端 {@code ClientController#bindingView}。 */
|
||||
public class BindingItem {
|
||||
|
||||
@SerializedName("bindingId")
|
||||
private String bindingId;
|
||||
|
||||
/** 被控端设备唯一标识,即连接时的目标设备 ID。 */
|
||||
@SerializedName("deviceUid")
|
||||
private String deviceUid;
|
||||
|
||||
@SerializedName("userId")
|
||||
private String userId;
|
||||
|
||||
/** OWNER / MEMBER。 */
|
||||
@SerializedName("role")
|
||||
private String role;
|
||||
|
||||
@SerializedName("alias")
|
||||
private String alias;
|
||||
|
||||
/** ACTIVE / REVOKED。 */
|
||||
@SerializedName("status")
|
||||
private String status;
|
||||
|
||||
public String getBindingId() {
|
||||
return bindingId;
|
||||
}
|
||||
|
||||
public String getDeviceUid() {
|
||||
return deviceUid;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public String getAlias() {
|
||||
return alias;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/** 下拉展示文案:有别名时优先展示别名。 */
|
||||
public String displayName() {
|
||||
if (alias != null && !alias.trim().isEmpty()) {
|
||||
return alias + " (" + deviceUid + ")";
|
||||
}
|
||||
return deviceUid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ttstd.controller.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** 已绑定设备列表响应,对应 {@code GET /api/client/bindings}。 */
|
||||
public class BindingsResponse {
|
||||
|
||||
@SerializedName("bindings")
|
||||
private List<BindingItem> bindings;
|
||||
|
||||
public List<BindingItem> getBindings() {
|
||||
return bindings == null ? Collections.emptyList() : bindings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ttstd.controller.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 登录请求体,对应服务端 {@code POST /api/auth/login}。 */
|
||||
public class LoginRequest {
|
||||
|
||||
@SerializedName("username")
|
||||
private final String username;
|
||||
|
||||
@SerializedName("password")
|
||||
private final String password;
|
||||
|
||||
/** 双因子动态码,未开启时传 null(Gson 默认忽略 null 字段)。 */
|
||||
@SerializedName("totpCode")
|
||||
private final String totpCode;
|
||||
|
||||
public LoginRequest(String username, String password) {
|
||||
this(username, password, null);
|
||||
}
|
||||
|
||||
public LoginRequest(String username, String password, String totpCode) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.totpCode = totpCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ttstd.controller.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 刷新令牌请求体,对应服务端 {@code POST /api/auth/refresh}。 */
|
||||
public class RefreshRequest {
|
||||
|
||||
@SerializedName("refreshToken")
|
||||
private final String refreshToken;
|
||||
|
||||
public RefreshRequest(String refreshToken) {
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.ttstd.controller.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* 登录 / 刷新令牌响应,对应服务端 {@code AuthDtos.TokenResponse}。
|
||||
*
|
||||
* <p>设备令牌场景 refreshToken 为 null;主控端(用户令牌)两者均会下发。
|
||||
*/
|
||||
public class TokenResponse {
|
||||
|
||||
@SerializedName("accessToken")
|
||||
private String accessToken;
|
||||
|
||||
@SerializedName("refreshToken")
|
||||
private String refreshToken;
|
||||
|
||||
/** access 令牌有效期(秒)。 */
|
||||
@SerializedName("expiresIn")
|
||||
private long expiresIn;
|
||||
|
||||
@SerializedName("sessionId")
|
||||
private String sessionId;
|
||||
|
||||
/** 用户 ID。 */
|
||||
@SerializedName("principalId")
|
||||
private String principalId;
|
||||
|
||||
/** 展示名(一般为用户名)。 */
|
||||
@SerializedName("displayName")
|
||||
private String displayName;
|
||||
|
||||
public String getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
public String getRefreshToken() {
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
public long getExpiresIn() {
|
||||
return expiresIn;
|
||||
}
|
||||
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public String getPrincipalId() {
|
||||
return principalId;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ttstd.controller.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 令牌校验响应,对应服务端 {@code GET /api/client/verify}。 */
|
||||
public class VerifyResponse {
|
||||
|
||||
@SerializedName("valid")
|
||||
private boolean valid;
|
||||
|
||||
/** USER / DEVICE。 */
|
||||
@SerializedName("principalType")
|
||||
private String principalType;
|
||||
|
||||
@SerializedName("principalId")
|
||||
private String principalId;
|
||||
|
||||
@SerializedName("displayName")
|
||||
private String displayName;
|
||||
|
||||
/** 过期时间(秒级时间戳)。 */
|
||||
@SerializedName("expiresAt")
|
||||
private long expiresAt;
|
||||
|
||||
/** 剩余有效秒数。 */
|
||||
@SerializedName("remainingSeconds")
|
||||
private long remainingSeconds;
|
||||
|
||||
@SerializedName("message")
|
||||
private String message;
|
||||
|
||||
public boolean isValid() {
|
||||
return valid;
|
||||
}
|
||||
|
||||
public String getPrincipalType() {
|
||||
return principalType;
|
||||
}
|
||||
|
||||
public String getPrincipalId() {
|
||||
return principalId;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public long getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public long getRemainingSeconds() {
|
||||
return remainingSeconds;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package com.ttstd.controller.signaling;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.ttstd.controller.BuildConfig;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
/**
|
||||
* 主控端 HTTP 客户端:对接安全信令服务器的账号体系与自助接口。
|
||||
*
|
||||
* - login(username,password) → accessToken + refreshToken(一次性,ses_ 前缀);
|
||||
* - refresh(refreshToken) → 新 accessToken(服务端可能轮换 refreshToken);
|
||||
* - verify() → 校验 accessToken 是否仍有效;
|
||||
* - bindings() → 本机可连接的被控端列表(仅已绑定设备);
|
||||
* - turnCredentials() → TURN 短期凭证(服务端开启时返回 iceServers)。
|
||||
*
|
||||
* 刷新单飞锁由调用方(MainActivity)保证并发只触发一次。
|
||||
*/
|
||||
public final class ApiClient {
|
||||
|
||||
private static final String TAG = "ControllerApiClient";
|
||||
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public static String apiBase() {
|
||||
return BuildConfig.API_BASE;
|
||||
}
|
||||
|
||||
public JsonObject login(String username, String password) {
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("username", username);
|
||||
body.addProperty("password", password);
|
||||
return post("/api/auth/login", body, false, null);
|
||||
}
|
||||
|
||||
public JsonObject register(String username, String password) {
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("username", username);
|
||||
body.addProperty("password", password);
|
||||
return post("/api/auth/register", body, false, null);
|
||||
}
|
||||
|
||||
/** 刷新令牌。需带 refreshToken(作为 body)。 */
|
||||
public JsonObject refresh(String refreshToken) {
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("refreshToken", refreshToken);
|
||||
return post("/api/auth/refresh", body, false, null);
|
||||
}
|
||||
|
||||
public JsonObject verify(String accessToken) {
|
||||
return get("/api/client/verify", accessToken);
|
||||
}
|
||||
|
||||
public JsonObject bindings(String accessToken) {
|
||||
return get("/api/client/bindings", accessToken);
|
||||
}
|
||||
|
||||
public JsonObject turnCredentials(String accessToken) {
|
||||
return get("/api/client/turn-credentials", accessToken);
|
||||
}
|
||||
|
||||
private JsonObject post(String path, JsonObject body, boolean auth, String token) {
|
||||
Request.Builder b = new Request.Builder().url(apiBase() + path)
|
||||
.post(RequestBody.create(body.toString(), JSON));
|
||||
if (auth && token != null) b.addHeader("Authorization", "Bearer " + token);
|
||||
return call(b.build(), path);
|
||||
}
|
||||
|
||||
private JsonObject get(String path, String token) {
|
||||
Request.Builder b = new Request.Builder().url(apiBase() + path).get();
|
||||
if (token != null) b.addHeader("Authorization", "Bearer " + token);
|
||||
return call(b.build(), path);
|
||||
}
|
||||
|
||||
private JsonObject call(Request request, String step) {
|
||||
try (Response resp = http.newCall(request).execute()) {
|
||||
ResponseBody body = resp.body();
|
||||
String text = body != null ? body.string() : "";
|
||||
if (!resp.isSuccessful()) {
|
||||
throw new ApiException("HTTP " + resp.code() + " " + text, resp.code());
|
||||
}
|
||||
return gson.fromJson(text, JsonObject.class);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, step + " 请求失败", e);
|
||||
if (e instanceof ApiException) throw (ApiException) e;
|
||||
throw new ApiException(e.getMessage(), -1);
|
||||
}
|
||||
}
|
||||
|
||||
/** API 调用异常,携带 HTTP 状态码(401 表示令牌失效)。 */
|
||||
public static class ApiException extends RuntimeException {
|
||||
public final int httpCode;
|
||||
public ApiException(String msg, int code) {
|
||||
super(msg);
|
||||
this.httpCode = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,34 @@ public final class TokenStore {
|
||||
private static final String KEY_REFRESH = "refresh_token";
|
||||
private static final String KEY_USERNAME = "username";
|
||||
|
||||
private static volatile TokenStore instance;
|
||||
|
||||
private final SharedPreferences sp;
|
||||
|
||||
public TokenStore(Context context) {
|
||||
this.sp = create(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 {@code BaseApplication#onCreate} 中初始化一次,供拦截器与仓库层全局访问。
|
||||
*/
|
||||
public static void init(Context context) {
|
||||
if (instance == null) {
|
||||
synchronized (TokenStore.class) {
|
||||
if (instance == null) {
|
||||
instance = new TokenStore(context.getApplicationContext());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static TokenStore get() {
|
||||
if (instance == null) {
|
||||
throw new IllegalStateException("TokenStore 未初始化,请先在 Application 中调用 TokenStore.init()");
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static SharedPreferences create(Context context) {
|
||||
try {
|
||||
MasterKey masterKey = new MasterKey.Builder(context)
|
||||
|
||||
99
WebRTCController/app/src/main/res/layout/activity_login.xml
Normal file
99
WebRTCController/app/src/main/res/layout/activity_login.xml
Normal file
@@ -0,0 +1,99 @@
|
||||
<?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 />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#0E1621"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="28dp"
|
||||
android:paddingTop="90dp"
|
||||
android:paddingEnd="28dp"
|
||||
android:paddingBottom="40dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/login_title"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="26sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/login_subtitle"
|
||||
android:textColor="#8A9BAE"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/til_username"
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="36dp"
|
||||
android:hint="@string/login_username"
|
||||
app:boxStrokeColor="#4A90D9"
|
||||
app:hintTextColor="#8A9BAE">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/et_username"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="text"
|
||||
android:maxLines="1"
|
||||
android:textColor="#FFFFFF" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/til_password"
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:hint="@string/login_password"
|
||||
app:boxStrokeColor="#4A90D9"
|
||||
app:hintTextColor="#8A9BAE"
|
||||
app:passwordToggleEnabled="true">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/et_password"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="textPassword"
|
||||
android:maxLines="1"
|
||||
android:textColor="#FFFFFF" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_login"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:layout_marginTop="28dp"
|
||||
android:text="@string/login_action"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/pb_loading"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="20dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</layout>
|
||||
@@ -1,212 +1,199 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:context=".activity.main.MainActivity">
|
||||
|
||||
<!-- 设置面板 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/setup_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
<data>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:text="WebRTC 控制端"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
</data>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="信令服务器地址:"
|
||||
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="16dp"
|
||||
android:hint="设备ID"
|
||||
android:inputType="text" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="目标被控设备ID:"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_target_device_id"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:hint="被控端设备ID"
|
||||
android:inputType="text"
|
||||
android:text="981964879" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:text="状态: 已停止"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_connect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="连接被控设备" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_disconnect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:enabled="false"
|
||||
android:text="断开连接" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 控制面板(连接后显示) -->
|
||||
<FrameLayout
|
||||
android:id="@+id/control_panel"
|
||||
android:background="#000000"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone">
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<!-- 远端视频渲染 -->
|
||||
<org.webrtc.SurfaceViewRenderer
|
||||
android:id="@+id/remote_video_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center" />
|
||||
|
||||
<!-- 自编码(自建 MediaCodec)屏幕串流渲染层,默认隐藏,切到自编码模式时显示 -->
|
||||
<SurfaceView
|
||||
android:id="@+id/self_codec_surface"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone" />
|
||||
|
||||
|
||||
<!-- 触摸控制层(覆盖在视频上方) -->
|
||||
<com.ttstd.controller.view.RemoteTouchView
|
||||
android:id="@+id/touch_overlay"
|
||||
<!-- 设置面板 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/setup_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:background="@android:color/transparent" />
|
||||
|
||||
<!-- 顶部状态栏 -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|start"
|
||||
android:background="#80000000"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp">
|
||||
android:padding="24dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_status_control"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="远程控制中"
|
||||
android:textColor="@android:color/white"
|
||||
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="16dp"
|
||||
android:hint="设备ID"
|
||||
android:inputType="text" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_stats"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="分辨率: - 帧率: - 延迟: -"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp" />
|
||||
android:text="目标被控设备ID:"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinner_resolution"
|
||||
android:layout_width="wrap_content"
|
||||
<EditText
|
||||
android:id="@+id/et_target_device_id"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:backgroundTint="#80FFFFFF" />
|
||||
android:layout_marginBottom="24dp"
|
||||
android:hint="被控端设备ID"
|
||||
android:inputType="text"
|
||||
android:text="981964879" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinner_fps"
|
||||
<TextView
|
||||
android:id="@+id/tv_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:backgroundTint="#80FFFFFF" />
|
||||
android:layout_marginBottom="24dp"
|
||||
android:text="状态: 已停止"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_disconnect_control"
|
||||
android:layout_width="wrap_content"
|
||||
android:id="@+id/btn_connect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:backgroundTint="#E53935"
|
||||
android:text="断开连接"
|
||||
android:textColor="@android:color/white" />
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="连接被控设备" />
|
||||
|
||||
<Switch
|
||||
android:id="@+id/switch_stream_mode"
|
||||
android:layout_width="wrap_content"
|
||||
<Button
|
||||
android:id="@+id/btn_disconnect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:text="自编码串流"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_stream_mode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="串流: WebRTC"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<Switch
|
||||
android:id="@+id/switch_record"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:enabled="false"
|
||||
android:text="录制屏幕"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_record_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="未录制"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp" />
|
||||
android:text="断开连接" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
<!-- 控制面板(连接后显示) -->
|
||||
<FrameLayout
|
||||
android:id="@+id/control_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000000"
|
||||
android:visibility="gone">
|
||||
|
||||
</FrameLayout>
|
||||
<!-- 远端视频渲染 -->
|
||||
<org.webrtc.SurfaceViewRenderer
|
||||
android:id="@+id/remote_video_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center" />
|
||||
|
||||
<!-- 自编码(自建 MediaCodec)屏幕串流渲染层,默认隐藏,切到自编码模式时显示 -->
|
||||
<SurfaceView
|
||||
android:id="@+id/self_codec_surface"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone" />
|
||||
|
||||
|
||||
<!-- 触摸控制层(覆盖在视频上方) -->
|
||||
<com.ttstd.controller.view.RemoteTouchView
|
||||
android:id="@+id/touch_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:background="@android:color/transparent" />
|
||||
|
||||
<!-- 顶部状态栏 -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|start"
|
||||
android:background="#80000000"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_status_control"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="远程控制中"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_stats"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="分辨率: - 帧率: - 延迟: -"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinner_resolution"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:backgroundTint="#80FFFFFF" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinner_fps"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:backgroundTint="#80FFFFFF" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_disconnect_control"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:backgroundTint="#E53935"
|
||||
android:text="断开连接"
|
||||
android:textColor="@android:color/white" />
|
||||
|
||||
<Switch
|
||||
android:id="@+id/switch_stream_mode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:text="自编码串流"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_stream_mode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="串流: WebRTC"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<Switch
|
||||
android:id="@+id/switch_record"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:enabled="false"
|
||||
android:text="录制屏幕"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_record_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="未录制"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</FrameLayout>
|
||||
</layout>
|
||||
|
||||
47
WebRTCController/app/src/main/res/layout/activity_splash.xml
Normal file
47
WebRTCController/app/src/main/res/layout/activity_splash.xml
Normal file
@@ -0,0 +1,47 @@
|
||||
<?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="#0E1621">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_app_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_slogan"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/splash_slogan"
|
||||
android:textColor="#8A9BAE"
|
||||
android:textSize="14sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/tv_app_name" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/pb_loading"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="64dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</layout>
|
||||
@@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_username"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:hint="@string/login_username_hint"
|
||||
android:inputType="text"
|
||||
android:autofillHints="username" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_password"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/login_password_hint"
|
||||
android:inputType="textPassword"
|
||||
android:autofillHints="password" />
|
||||
</LinearLayout>
|
||||
@@ -16,4 +16,17 @@
|
||||
<!-- 登录 -->
|
||||
<string name="login_username_hint">用户名</string>
|
||||
<string name="login_password_hint">密码</string>
|
||||
|
||||
<!-- 启动页 -->
|
||||
<string name="splash_slogan">远程协助 · 安全连接</string>
|
||||
|
||||
<!-- 登录页 -->
|
||||
<string name="login_title">欢迎回来</string>
|
||||
<string name="login_subtitle">请登录您的账号以连接被控设备</string>
|
||||
<string name="login_username">用户名</string>
|
||||
<string name="login_password">密码</string>
|
||||
<string name="login_action">登录</string>
|
||||
<string name="login_in_progress">登录中…</string>
|
||||
<string name="login_success">登录成功</string>
|
||||
<string name="logout">退出登录</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user