feat(controlled, controller): 新增配对码生成与兑换功能

被控端:新增生成一次性配对码并弹窗展示,支持倒计时与复制;控制端:新增输入配对码兑换绑定,刷新设备列表,并在设备列表中显示在线状态和点击连接。同时添加统一 401 拦截器,令牌失效时触发重新激活。
This commit is contained in:
TongTongStudio
2026-08-03 01:57:42 +08:00
parent ded6fe5ab7
commit e689b999e6
29 changed files with 1096 additions and 86 deletions

View File

@@ -5,19 +5,25 @@ import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.app.AlertDialog;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.ServiceConnection;
import android.content.pm.PackageManager;
import android.media.projection.MediaProjectionManager;
import android.os.Build;
import android.os.CountDownTimer;
import android.os.IBinder;
import android.util.Log;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.TextView;
import android.widget.Toast;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -29,12 +35,18 @@ import com.ttstd.controlled.accessibility.AccessibilityServiceHelper;
import com.ttstd.controlled.base.mvvm.BaseMvvmActivity;
import com.ttstd.controlled.databinding.ActivityMainBinding;
import com.ttstd.controlled.activity.settings.SettingsActivity;
import com.ttstd.controlled.network.DeviceRepository;
import com.ttstd.controlled.network.model.PairingCodeResponse;
import com.ttstd.controlled.service.ScreenCaptureService;
import com.ttstd.controlled.utils.SignatureUtils;
import com.ttstd.controlled.webrtc.WebRtcClient;
import org.webrtc.RendererCommon;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBinding>
implements ScreenCaptureService.ServiceStateListener {
@@ -50,6 +62,10 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
/** 是否为特权应用(系统签名或无 root 权限),缓存后避免重复在主线程执行 su 检测。 */
private boolean isPrivileged = false;
private AlertDialog accessibilityDialog;
/** 配对码生成请求的订阅,用于界面销毁时释放。 */
private Disposable pairingDisposable;
/** 当前配对码倒计时,用于界面销毁时取消。 */
private CountDownTimer pairingCountDown;
private final ServiceConnection serviceConnection = new ServiceConnection() {
@Override
@@ -93,6 +109,9 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
binding.btnOpenSettings.setOnClickListener(v ->
startActivity(new Intent(this, SettingsActivity.class)));
// 生成一次性配对码,供其他用户(控制端)兑换绑定
binding.btnGeneratePairing.setOnClickListener(v -> onGeneratePairingCode());
updateUI(false);
}
@@ -239,6 +258,96 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
Toast.makeText(this, "屏幕共享已停止", Toast.LENGTH_SHORT).show();
}
/**
* 生成一次性配对码并弹窗展示。配对码由服务端生成,明文仅回显一次,
* 有效期默认 10 分钟;过期后需重新生成。复制按钮便于用户分享给他人。
*/
private void onGeneratePairingCode() {
DeviceRepository repository = new DeviceRepository(this);
if (pairingDisposable != null && !pairingDisposable.isDisposed()) {
pairingDisposable.dispose();
}
pairingDisposable = repository.generatePairingCode()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
this::showPairingCodeDialog,
err -> Toast.makeText(this,
"生成配对码失败:" + err.getMessage(), Toast.LENGTH_LONG).show()
);
}
/** 弹窗展示配对码、剩余有效期与复制按钮。 */
private void showPairingCodeDialog(PairingCodeResponse resp) {
if (resp == null || resp.getCode() == null || resp.getCode().isEmpty()) {
Toast.makeText(this, "生成配对码失败:服务端未返回有效码", Toast.LENGTH_LONG).show();
return;
}
final String code = resp.getCode();
int totalSec = resp.getExpiresInSeconds() > 0 ? resp.getExpiresInSeconds() : 600;
TextView tvCode = new TextView(this);
tvCode.setText(code);
tvCode.setTextSize(32);
tvCode.setPadding(40, 30, 40, 10);
tvCode.setTextIsSelectable(true);
TextView tvTip = new TextView(this);
tvTip.setPadding(40, 0, 40, 20);
tvTip.setText("请在其他设备的控制端输入此配对码以完成绑定10分钟内有效");
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle("邀请绑定 - 配对码")
.setView(composePairingView(tvCode, tvTip))
.setPositiveButton("复制", (d, w) -> {
ClipboardManager cm = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
if (cm != null) {
cm.setPrimaryClip(ClipData.newPlainText("pairing_code", code));
Toast.makeText(this, "配对码已复制", Toast.LENGTH_SHORT).show();
}
})
.setNegativeButton("关闭", (d, w) -> {
if (pairingCountDown != null) pairingCountDown.cancel();
d.dismiss();
})
.setCancelable(false)
.create();
// 倒计时更新提示文案
final long tickMs = 1000;
pairingCountDown = new CountDownTimer(totalSec * 1000L, tickMs) {
@Override
public void onTick(long millisUntilFinished) {
long sec = TimeUnit.MILLISECONDS.toSeconds(millisUntilFinished);
tvTip.setText(String.format(Locale.getDefault(),
"请在其他设备的控制端输入此配对码以完成绑定(剩余 %d 秒)", sec));
}
@Override
public void onFinish() {
tvTip.setText("配对码已过期,请重新生成");
tvCode.setEnabled(false);
}
};
dialog.setOnDismissListener(d -> {
if (pairingCountDown != null) {
pairingCountDown.cancel();
pairingCountDown = null;
}
});
dialog.show();
pairingCountDown.start();
}
/** 组合配对码展示视图(码 + 提示)。 */
private android.widget.LinearLayout composePairingView(TextView tvCode, TextView tvTip) {
android.widget.LinearLayout layout = new android.widget.LinearLayout(this);
layout.setOrientation(android.widget.LinearLayout.VERTICAL);
layout.addView(tvCode);
layout.addView(tvTip);
return layout;
}
private void setupLocalPreview() {
if (isBound && screenCaptureService != null && !isLocalViewInitialized) {
WebRtcClient webRtcClient = screenCaptureService.getWebRtcClient();
@@ -262,6 +371,14 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
@Override
protected void onDestroy() {
super.onDestroy();
if (pairingDisposable != null && !pairingDisposable.isDisposed()) {
pairingDisposable.dispose();
pairingDisposable = null;
}
if (pairingCountDown != null) {
pairingCountDown.cancel();
pairingCountDown = null;
}
if (accessibilityDialog != null && accessibilityDialog.isShowing()) {
accessibilityDialog.dismiss();
accessibilityDialog = null;

View File

@@ -9,6 +9,8 @@ import androidx.multidex.MultiDex;
import com.tencent.mmkv.MMKV;
import com.ttstd.controlled.network.ChainContext;
public class BaseApplication extends Application {
private static final String TAG = "BaseApplication";
@@ -33,6 +35,7 @@ public class BaseApplication extends Application {
super.onCreate();
Log.e(TAG, "onCreate: ");
mAppContext = getApplicationContext();
ChainContext.init(this);
init();
}

View File

@@ -0,0 +1,23 @@
package com.ttstd.controlled.network;
import android.content.Context;
/**
* 在 OkHttp 拦截器(无 Activity/Context 引用)中获取 Application Context 的简易持有者。
* 由 {@code BaseApplication} 在 onCreate 中初始化。
*/
public final class ChainContext {
private static Context sAppContext;
private ChainContext() {
}
public static void init(Context context) {
sAppContext = context.getApplicationContext();
}
public static Context getAppContext() {
return sAppContext;
}
}

View File

@@ -1,6 +1,7 @@
package com.ttstd.controlled.network;
import com.google.gson.JsonObject;
import com.ttstd.controlled.network.model.PairingCodeResponse;
import com.ttstd.controlled.network.model.ProvisionRequest;
import com.ttstd.controlled.network.model.ProvisionResponse;
import com.ttstd.controlled.network.model.TokenRequest;
@@ -37,4 +38,10 @@ public interface DeviceApi {
*/
@GET("/api/client/turn-credentials")
Single<JsonObject> turnCredentials(@Header("Authorization") String bearerToken);
/**
* 生成一次性配对码,供其他用户(控制端)兑换绑定。需携带设备令牌。
*/
@POST("/api/client/device/pairing-code")
Single<PairingCodeResponse> pairingCode(@Header("Authorization") String bearerToken);
}

View File

@@ -6,6 +6,7 @@ import android.text.TextUtils;
import com.google.gson.JsonObject;
import com.ttstd.controlled.BuildConfig;
import com.ttstd.controlled.network.model.PairingCodeResponse;
import com.ttstd.controlled.network.model.ProvisionRequest;
import com.ttstd.controlled.network.model.ProvisionResponse;
import com.ttstd.controlled.network.model.TokenRequest;
@@ -13,6 +14,7 @@ import com.ttstd.controlled.network.model.TokenResponse;
import com.ttstd.controlled.utils.DeviceSecretStore;
import com.ttstd.controlled.utils.DeviceUtils;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
@@ -66,7 +68,8 @@ public class DeviceRepository {
return exchangeToken(store.getDeviceUid(), store.getDeviceSecret());
}
return provision().flatMap(resp -> exchangeToken(resp.getDeviceUid(), resp.getDeviceSecret()));
}).subscribeOn(Schedulers.io());
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
/** 强制重新激活:清空本地凭据后走完整 provision 流程。用于激活页「刷新重试」。 */
@@ -74,7 +77,8 @@ public class DeviceRepository {
return Single.defer(() -> {
store.clear();
return provision().flatMap(resp -> exchangeToken(resp.getDeviceUid(), resp.getDeviceSecret()));
}).subscribeOn(Schedulers.io());
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
/** 第一步:用 SN + HMAC 证明出厂身份,成功后立即加密落盘。 */
@@ -120,6 +124,21 @@ public class DeviceRepository {
}
return api.turnCredentials("Bearer " + accessToken)
.onErrorReturnItem(new JsonObject())
.subscribeOn(Schedulers.io());
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
/**
* 生成一次性配对码,供其他用户(控制端)兑换绑定。
* 需设备已激活且持有有效的 accessToken。
*/
public Single<PairingCodeResponse> generatePairingCode() {
String token = getAccessToken();
if (TextUtils.isEmpty(token)) {
return Single.error(new IllegalStateException("设备尚未激活,无法生成配对码"));
}
return api.pairingCode("Bearer " + token)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
}

View File

@@ -4,6 +4,7 @@ import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.ttstd.controlled.BuildConfig;
import com.ttstd.controlled.network.UnauthorizedInterceptor;
import java.util.concurrent.TimeUnit;
@@ -35,7 +36,8 @@ public final class RetrofitClient {
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true);
.retryOnConnectionFailure(true)
.addInterceptor(new UnauthorizedInterceptor());
if (BuildConfig.DEBUG) {
HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
logging.setLevel(HttpLoggingInterceptor.Level.BODY);

View File

@@ -0,0 +1,57 @@
package com.ttstd.controlled.network;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import com.ttstd.controlled.utils.DeviceSecretStore;
import java.io.IOException;
import okhttp3.Interceptor;
import okhttp3.Response;
/**
* 被控端统一 401 拦截器。
*
* <p>被控端没有「登录页」概念401 表示服务端下发的 accessToken 已失效。此时应清理本地
* 激活凭据deviceUid / deviceSecret / accessToken并通过本地广播通知
* {@code ScreenCaptureService} 重新走激活流程provision / token 换取)。
*
* <p>注意:令牌失效可能伴随大量并发请求同时返回 401这里用 {@code notified} 哨兵保证
* 一次失效只清一次凭据、只发一次重激活广播。
*/
public class UnauthorizedInterceptor implements Interceptor {
/** ScreenCaptureService 监听此本地广播以触发重新激活。 */
public static final String ACTION_TOKEN_INVALID = "com.ttstd.controlled.action.TOKEN_INVALID";
private static final String TAG = "UnauthorizedInterceptor";
private static volatile boolean notified = false;
@NonNull
@Override
public Response intercept(@NonNull Chain chain) throws IOException {
Response response = chain.proceed(chain.request());
if (response.code() == 401) {
Log.w(TAG, "收到 401 未授权响应,清理激活凭据并触发重新激活");
if (!notified) {
notified = true;
// 清理本地激活凭据,强制重新激活。
Context appCtx = ChainContext.getAppContext();
if (appCtx != null) {
new DeviceSecretStore(appCtx).clear();
}
if (appCtx != null) {
LocalBroadcastManager.getInstance(appCtx)
.sendBroadcast(new Intent(ACTION_TOKEN_INVALID));
}
notified = false;
}
}
return response;
}
}

View File

@@ -0,0 +1,34 @@
package com.ttstd.controlled.network.model;
import com.google.gson.annotations.SerializedName;
/**
* 被控端生成的一次性配对码响应。
* 对应服务端 {@code POST /api/client/device/pairing-code}。
*/
public class PairingCodeResponse {
/** 一次性配对码明文仅回显一次10 分钟有效。 */
@SerializedName("code")
private String code;
/** 有效期(秒)。 */
@SerializedName("expiresInSeconds")
private int expiresInSeconds;
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public int getExpiresInSeconds() {
return expiresInSeconds;
}
public void setExpiresInSeconds(int expiresInSeconds) {
this.expiresInSeconds = expiresInSeconds;
}
}

View File

@@ -6,8 +6,10 @@ import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ServiceInfo;
import android.media.projection.MediaProjection;
import android.os.Binder;
@@ -40,6 +42,7 @@ import com.ttstd.controlled.input.RootShellInputUtils;
import com.ttstd.controlled.input.ShellInputUtils;
import com.ttstd.controlled.input.SystemInputUtils;
import com.ttstd.controlled.signaling.SignalMessage;
import com.ttstd.controlled.network.UnauthorizedInterceptor;
import com.ttstd.controlled.signaling.WebSocketClient;
import com.ttstd.controlled.utils.AuthSettings;
import com.ttstd.controlled.utils.InputSettings;
@@ -157,8 +160,30 @@ public class ScreenCaptureService extends BaseService {
instance = this;
createNotificationChannel();
// 401 拦截器发来的令牌失效广播:清理本地令牌后重新走激活流程。
IntentFilter filter = new IntentFilter(UnauthorizedInterceptor.ACTION_TOKEN_INVALID);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(tokenInvalidReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
} else {
registerReceiver(tokenInvalidReceiver, filter);
}
}
/** 监听 REST 接口 401触发重新激活。 */
private final BroadcastReceiver tokenInvalidReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
Log.w(TAG, "收到令牌失效广播,清理 accessToken 并重新激活");
accessToken = null;
if (pendingServerUrl != null) {
pendingServerUrl = null;
}
// 交由 ViewModel 走 provision / token 重新激活并连接。
mViewModel.activate();
}
};
/**
* 订阅 ViewModel 的激活 / 令牌事件。
* BaseService 实现了 LifecycleOwnerobserve 会随 Service 销毁自动解绑。
@@ -718,6 +743,11 @@ public class ScreenCaptureService extends BaseService {
@Override
public void onDestroy() {
super.onDestroy();
try {
unregisterReceiver(tokenInvalidReceiver);
} catch (IllegalArgumentException ignore) {
// 未注册或已注销时忽略
}
isShuttingDown = true;
sResultCode = Activity.RESULT_CANCELED;
sResultData = null;

View File

@@ -39,6 +39,14 @@
android:textSize="16sp"
android:textStyle="bold" />
<!-- 邀请绑定:生成一次性配对码,供其他用户(控制端)兑换绑定 -->
<Button
android:id="@+id/btn_generate_pairing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/btn_generate_pairing" />
<!-- 安全与输入设置入口 -->
<Button
android:id="@+id/btn_open_settings"

View File

@@ -41,6 +41,7 @@
<!-- 设置页面 -->
<string name="settings_title">安全与输入设置</string>
<string name="btn_open_settings">安全与输入设置</string>
<string name="btn_generate_pairing">生成配对码(邀请绑定)</string>
<!-- 模拟点击方式 -->
<string name="input_method_label">模拟点击方式</string>