diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java index 8e21c41..6dfe5fd 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java @@ -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 implements ScreenCaptureService.ServiceStateListener { @@ -50,6 +62,10 @@ public class MainActivity extends BaseMvvmActivity startActivity(new Intent(this, SettingsActivity.class))); + // 生成一次性配对码,供其他用户(控制端)兑换绑定 + binding.btnGeneratePairing.setOnClickListener(v -> onGeneratePairingCode()); + updateUI(false); } @@ -239,6 +258,96 @@ public class MainActivity extends BaseMvvmActivity 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 turnCredentials(@Header("Authorization") String bearerToken); + + /** + * 生成一次性配对码,供其他用户(控制端)兑换绑定。需携带设备令牌。 + */ + @POST("/api/client/device/pairing-code") + Single pairingCode(@Header("Authorization") String bearerToken); } diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/DeviceRepository.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/DeviceRepository.java index 55368aa..6e2befd 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/DeviceRepository.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/DeviceRepository.java @@ -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 generatePairingCode() { + String token = getAccessToken(); + if (TextUtils.isEmpty(token)) { + return Single.error(new IllegalStateException("设备尚未激活,无法生成配对码")); + } + return api.pairingCode("Bearer " + token) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()); } } diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/RetrofitClient.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/RetrofitClient.java index 7e2f13c..7991b4b 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/RetrofitClient.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/RetrofitClient.java @@ -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); diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/UnauthorizedInterceptor.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/UnauthorizedInterceptor.java new file mode 100644 index 0000000..552816d --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/UnauthorizedInterceptor.java @@ -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 拦截器。 + * + *

被控端没有「登录页」概念,401 表示服务端下发的 accessToken 已失效。此时应清理本地 + * 激活凭据(deviceUid / deviceSecret / accessToken),并通过本地广播通知 + * {@code ScreenCaptureService} 重新走激活流程(provision / token 换取)。 + * + *

注意:令牌失效可能伴随大量并发请求同时返回 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; + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/model/PairingCodeResponse.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/model/PairingCodeResponse.java new file mode 100644 index 0000000..8a6046c --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/network/model/PairingCodeResponse.java @@ -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; + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java index b1aa60e..dc4d75e 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java @@ -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 实现了 LifecycleOwner,observe 会随 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; diff --git a/WebRTCControlled/app/src/main/res/layout/activity_main.xml b/WebRTCControlled/app/src/main/res/layout/activity_main.xml index 76cecb7..ee4212e 100644 --- a/WebRTCControlled/app/src/main/res/layout/activity_main.xml +++ b/WebRTCControlled/app/src/main/res/layout/activity_main.xml @@ -39,6 +39,14 @@ android:textSize="16sp" android:textStyle="bold" /> + +