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

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

View File

@@ -1,6 +1,8 @@
plugins {
id 'com.android.application'
id 'com.google.protobuf'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.kapt'
}
def releaseTime() {
@@ -23,7 +25,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'
// ProtobufDataChannel 控制指令二进制)
implementation 'com.google.protobuf:protobuf-java:3.25.1'
// WebRTC
implementation 'io.github.webrtc-sdk:android:144.7559.09'
// implementation 'org.webrtc:google-webrtc:1.0.32006'
// OkHttp for WebSocket
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
//生命周期管理
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'
// ProtobufDataChannel 控制指令二进制)
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'
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {
// 启动检查期间屏蔽返回,避免路由状态错乱
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
package com.ttstd.controller.config;
/** 主控端全局配置。 */
public final class CommonConfig {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
/** 双因子动态码,未开启时传 nullGson 默认忽略 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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