feat: 被控端增加无障碍检查,flutter固定获取Android_Id

This commit is contained in:
2026-07-16 21:56:12 +08:00
parent 68a863c66a
commit 45ee9ef60c
13 changed files with 506 additions and 72 deletions

View File

@@ -35,6 +35,19 @@
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaProjection" /> android:foregroundServiceType="mediaProjection" />
<!-- 键盘/输入模拟无障碍服务(无需系统签名/root用于模拟全局按键与手势 -->
<service
android:name=".accessibility.KeyboardAccessibilityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
</application> </application>
</manifest> </manifest>

View File

@@ -0,0 +1,82 @@
package com.ttstd.controlled.accessibility;
import android.accessibilityservice.AccessibilityServiceInfo;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.provider.Settings;
import android.util.Log;
import android.view.accessibility.AccessibilityManager;
import java.util.List;
public class AccessibilityServiceHelper {
private static final String TAG = "AccessibilityHelper";
/** 检查本应用在系统设置中是否已开启该无障碍服务。 */
public static boolean isAccessibilityEnabled(Context context) {
String enabled = Settings.Secure.getString(
context.getContentResolver(), Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES);
if (enabled == null || enabled.isEmpty()) {
return false;
}
String component = context.getPackageName() + "/" + KeyboardAccessibilityService.class.getName();
return enabled.contains(component);
}
/**
* 检查特定的无障碍服务是否已启用
*
* @param context 上下文对象
* @param serviceClass 你的无障碍服务类(例如 MyAccessibilityService.class
* @return true 已开启false 未开启
*/
public static boolean isAccessibilityServiceEnabled(Context context, Class<?> serviceClass) {
// 获取 AccessibilityManager 系统服务
AccessibilityManager am = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
if (am == null) {
return false;
}
// 获取所有已启用的无障碍服务列表
List<AccessibilityServiceInfo> enabledServices = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK);
// 构建你服务的完整组件标识符
String expectedServiceId = new ComponentName(context, serviceClass).flattenToShortString();
Log.e(TAG, "isAccessibilityServiceEnabled: expectedServiceId = " + expectedServiceId);
for (AccessibilityServiceInfo serviceInfo : enabledServices) {
Log.e(TAG, "isAccessibilityServiceEnabled: serviceInfo.getId() = " + serviceInfo.getId());
if (expectedServiceId.equals(serviceInfo.getId())) {
return true;
}
}
return false;
}
/**
* 跳转到系统的无障碍服务设置页面
*
* @param context 上下文对象
*/
public static void jumpToAccessibilitySettings(Context context) {
try {
Intent intent = new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // 通常从应用上下文启动时需要此标志
String str = context.getPackageName() + "/" + KeyboardAccessibilityService.class.getName();
Bundle bundle = new Bundle();
bundle.putString(":settings:fragment_args_key", str);
intent.putExtra(":settings:fragment_args_key", str);
intent.putExtra(":settings:show_fragment_args", bundle);
context.startActivity(intent);
} catch (Exception e) {
// 处理跳转失败的情况,例如某些设备可能没有标准的设置界面
e.printStackTrace();
// 可以尝试跳转到系统通用设置作为备选方案
Intent fallbackIntent = new Intent(Settings.ACTION_SETTINGS);
fallbackIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(fallbackIntent);
}
}
}

View File

@@ -0,0 +1,112 @@
package com.ttstd.controlled.accessibility;
import android.accessibilityservice.AccessibilityService;
import android.accessibilityservice.AccessibilityServiceInfo;
import android.content.Context;
import android.os.Build;
import android.provider.Settings;
import android.util.Log;
import android.view.KeyEvent;
import android.view.accessibility.AccessibilityEvent;
/**
* 键盘无障碍服务。
* <p>
* 用于在应用没有系统签名、也没有 root 权限时,通过无障碍能力模拟系统级全局按键。
* 具体能力:
* - {@link #pressKey(int)}:将部分 KeyEvent 按键码映射为 AccessibilityService 的全局动作
* (返回 / 主页 / 多任务 / 通知 / 音量等),通过 {@link #performGlobalAction(int)} 触发。
* - {@link #dispatchGesture(GestureDescription, ...)}(继承自父类):供
* {@code AccessibilityInputUtils} 模拟触摸/滑动/长按等手势。
* <p>
* 注意:无障碍服务无法直接模拟任意字符按键(如字母、数字),只能触发上述全局动作。
*/
public class KeyboardAccessibilityService extends AccessibilityService {
private static final String TAG = "KeyboardA11yService";
private static KeyboardAccessibilityService sInstance;
/** 获取当前正在运行的实例(未启用时返回 null。 */
public static KeyboardAccessibilityService getInstance() {
return sInstance;
}
/** 无障碍服务是否已经启用并运行。 */
public static boolean isRunning() {
return sInstance != null;
}
@Override
protected void onServiceConnected() {
super.onServiceConnected();
sInstance = this;
// 也可以通过 res/xml/accessibility_service_config.xml 配置,这里做一次兜底设置。
AccessibilityServiceInfo info = new AccessibilityServiceInfo();
info.eventTypes = AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
| AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED;
info.feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC;
info.notificationTimeout = 100;
setServiceInfo(info);
Log.i(TAG, "Accessibility service connected");
}
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
// 本服务仅用于模拟输入,无需处理具体无障碍事件。
}
@Override
public void onInterrupt() {
}
@Override
public void onDestroy() {
super.onDestroy();
if (sInstance == this) {
sInstance = null;
}
Log.i(TAG, "Accessibility service destroyed");
}
/**
* 通过无障碍服务的全局动作模拟按键。
* 仅支持系统级全局按键,对于无障碍无法直接模拟的按键码返回 false。
*
* @param keyCode Android {@link KeyEvent} 按键码
* @return 是否成功模拟true 表示已触发对应全局动作)
*/
public boolean pressKey(int keyCode) {
int globalAction = mapKeyToGlobalAction(keyCode);
if (globalAction >= 0) {
return performGlobalAction(globalAction);
}
Log.w(TAG, "Unsupported key code for accessibility: " + keyCode);
return false;
}
private static int mapKeyToGlobalAction(int keyCode) {
switch (keyCode) {
case KeyEvent.KEYCODE_BACK:
return GLOBAL_ACTION_BACK;
case KeyEvent.KEYCODE_HOME:
return GLOBAL_ACTION_HOME;
case KeyEvent.KEYCODE_APP_SWITCH:
return GLOBAL_ACTION_RECENTS;
case KeyEvent.KEYCODE_NOTIFICATION:
return GLOBAL_ACTION_NOTIFICATIONS;
case KeyEvent.KEYCODE_VOLUME_UP:
// GLOBAL_ACTION_VOLUME_UP = 11 (API 29)
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? 11 : -1;
case KeyEvent.KEYCODE_VOLUME_DOWN:
// GLOBAL_ACTION_VOLUME_DOWN = 12 (API 29)
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? 12 : -1;
case KeyEvent.KEYCODE_VOLUME_MUTE:
// GLOBAL_ACTION_VOLUME_MUTE = 13 (API 29)
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? 13 : -1;
default:
return -1;
}
}
}

View File

@@ -4,11 +4,13 @@ import android.Manifest;
import android.content.ComponentName; import android.content.ComponentName;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.app.AlertDialog;
import android.content.ServiceConnection; import android.content.ServiceConnection;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.media.projection.MediaProjectionManager; import android.media.projection.MediaProjectionManager;
import android.os.Build; import android.os.Build;
import android.os.IBinder; import android.os.IBinder;
import android.provider.Settings;
import android.util.Log; import android.util.Log;
import android.widget.Toast; import android.widget.Toast;
@@ -18,9 +20,11 @@ import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import com.ttstd.controlled.R; import com.ttstd.controlled.R;
import com.ttstd.controlled.accessibility.AccessibilityServiceHelper;
import com.ttstd.controlled.base.BaseMvvmActivity; import com.ttstd.controlled.base.BaseMvvmActivity;
import com.ttstd.controlled.databinding.ActivityMainBinding; import com.ttstd.controlled.databinding.ActivityMainBinding;
import com.ttstd.controlled.service.ScreenCaptureService; import com.ttstd.controlled.service.ScreenCaptureService;
import com.ttstd.controlled.accessibility.KeyboardAccessibilityService;
import com.ttstd.controlled.utils.DeviceUtils; import com.ttstd.controlled.utils.DeviceUtils;
import com.ttstd.controlled.utils.SignatureUtils; import com.ttstd.controlled.utils.SignatureUtils;
import com.ttstd.controlled.webrtc.WebRtcClient; import com.ttstd.controlled.webrtc.WebRtcClient;
@@ -39,6 +43,10 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
private boolean isBound = false; private boolean isBound = false;
private boolean isLocalViewInitialized = false; private boolean isLocalViewInitialized = false;
/** 是否为特权应用(系统签名或无 root 权限),缓存后避免重复在主线程执行 su 检测。 */
private boolean isPrivileged = false;
private AlertDialog accessibilityDialog;
private final ServiceConnection serviceConnection = new ServiceConnection() { private final ServiceConnection serviceConnection = new ServiceConnection() {
@Override @Override
public void onServiceConnected(ComponentName name, IBinder service) { public void onServiceConnected(ComponentName name, IBinder service) {
@@ -67,7 +75,7 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
// 默认服务器地址 // 默认服务器地址
binding.etServerUrl.setText("ws://175.178.213.60:8088/ws/signal"); binding.etServerUrl.setText("ws://175.178.213.60:8088/ws/signal");
// 生成设备ID // 生成设备ID
binding.etDeviceId.setText(DeviceUtils.getSystemSerialNumber()); // binding.etDeviceId.setText(DeviceUtils.getSystemSerialNumber());
binding.btnStart.setOnClickListener(v -> startScreenSharing()); binding.btnStart.setOnClickListener(v -> startScreenSharing());
binding.btnStop.setOnClickListener(v -> stopScreenSharing()); binding.btnStop.setOnClickListener(v -> stopScreenSharing());
@@ -84,9 +92,45 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
Log.e(TAG, "initData: isSystemUid = " + SignatureUtils.isSystemUid()); Log.e(TAG, "initData: isSystemUid = " + SignatureUtils.isSystemUid());
Log.e(TAG, "initData: isDeviceRooted = " + SignatureUtils.isDeviceRooted()); Log.e(TAG, "initData: isDeviceRooted = " + SignatureUtils.isDeviceRooted());
Log.e(TAG, "initData: isAppHasRootPermission = " + SignatureUtils.isAppHasRootPermission()); Log.e(TAG, "initData: isAppHasRootPermission = " + SignatureUtils.isAppHasRootPermission());
// 特权应用判定:系统签名(含共享系统 UID / 系统 UID或已获取 Root 权限。
// 特权应用无需依赖无障碍服务即可模拟输入,缓存结果避免每次 onResume 在主线程检测 su。
boolean systemSigned = SignatureUtils.isSystemSignature(this)
|| SignatureUtils.isSharedSystemUid(this)
|| SignatureUtils.isSystemUid();
boolean rooted = SignatureUtils.isDeviceRooted() && SignatureUtils.isAppHasRootPermission();
isPrivileged = systemSigned || rooted;
Log.e(TAG, "initData: isPrivileged = " + isPrivileged);
}
@Override
protected void onResume() {
super.onResume();
}
/** 弹窗提示开启无障碍服务;同一运行周期内仅提示一次。 */
private void showAccessibilityPermissionDialog() {
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle(R.string.accessibility_dialog_title)
.setMessage(R.string.accessibility_dialog_message)
.setCancelable(false)
.setPositiveButton(R.string.accessibility_dialog_go, (dialog, which) -> {
AccessibilityServiceHelper.jumpToAccessibilitySettings(this);
})
.setNegativeButton(R.string.accessibility_dialog_cancel, (dialog, which) -> {
dialog.dismiss();
});
accessibilityDialog = builder.create();
accessibilityDialog.show();
} }
private void startScreenSharing() { private void startScreenSharing() {
// 非特权应用且未开启无障碍服务时,弹窗提示用户前往系统设置开启。
if (!isPrivileged && !AccessibilityServiceHelper.isAccessibilityEnabled(this)) {
showAccessibilityPermissionDialog();
return;
}
// 检查通知权限 (Android 13+) // 检查通知权限 (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
@@ -185,6 +229,10 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
@Override @Override
protected void onDestroy() { protected void onDestroy() {
super.onDestroy(); super.onDestroy();
if (accessibilityDialog != null && accessibilityDialog.isShowing()) {
accessibilityDialog.dismiss();
accessibilityDialog = null;
}
if (isBound) { if (isBound) {
if (screenCaptureService != null) { if (screenCaptureService != null) {
screenCaptureService.setStateListener(null); screenCaptureService.setStateListener(null);

View File

@@ -0,0 +1,106 @@
package com.ttstd.controlled.input;
import android.accessibilityservice.GestureDescription;
import android.graphics.Path;
import android.os.SystemClock;
import android.util.Log;
import android.view.MotionEvent;
import com.ttstd.controlled.accessibility.KeyboardAccessibilityService;
/**
* 基于无障碍服务AccessibilityService的输入执行器。
* <p>
* 适用于没有系统签名、也没有 root 权限的普通应用:
* - 触摸 / 滑动 / 长按 / 连续手势:通过 AccessibilityService.dispatchGesture 模拟;
* - 按键:通过 KeyboardAccessibilityService.pressKey 模拟系统级全局按键。
* <p>
* 注意:无障碍无法模拟任意字符按键,仅支持返回 / 主页 / 多任务 / 通知 / 音量等全局动作。
*/
public class AccessibilityInputUtils implements InputExecutor {
private static final String TAG = "AccessibilityInputUtils";
// 连续手势MOTION_EVENT的累积路径
private Path motionPath;
private long motionStartTime;
private KeyboardAccessibilityService getService() {
return KeyboardAccessibilityService.getInstance();
}
@Override
public void injectTap(int x, int y) {
Path path = new Path();
path.moveTo(x, y);
// 同一点、短时长即模拟“按下-抬起”的点击
dispatch(path, 1, 50);
}
@Override
public void injectLongPress(int x, int y) {
Path path = new Path();
path.moveTo(x, y);
dispatch(path, 1, 1000);
}
@Override
public void injectSwipe(int x1, int y1, int x2, int y2, long duration) {
Path path = new Path();
path.moveTo(x1, y1);
path.lineTo(x2, y2);
dispatch(path, 0, Math.max(50, duration));
}
@Override
public void injectKeyEvent(int keyCode) {
KeyboardAccessibilityService svc = getService();
if (svc == null) {
Log.w(TAG, "Accessibility service not running, cannot inject key");
return;
}
boolean handled = svc.pressKey(keyCode);
if (!handled) {
Log.w(TAG, "Key code " + keyCode + " is not supported via accessibility");
}
}
@Override
public void injectMotionEvent(int action, int x, int y) {
switch (action) {
case MotionEvent.ACTION_DOWN:
motionPath = new Path();
motionPath.moveTo(x, y);
motionStartTime = SystemClock.uptimeMillis();
break;
case MotionEvent.ACTION_MOVE:
if (motionPath != null) {
motionPath.lineTo(x, y);
}
break;
case MotionEvent.ACTION_UP:
if (motionPath != null) {
long duration = Math.max(1, SystemClock.uptimeMillis() - motionStartTime);
dispatch(motionPath, 0, duration);
motionPath = null;
}
break;
default:
Log.w(TAG, "Unknown motion action: " + action);
}
}
private void dispatch(Path path, long startOffset, long duration) {
KeyboardAccessibilityService svc = getService();
if (svc == null) {
Log.w(TAG, "Accessibility service not running, cannot dispatch gesture");
return;
}
GestureDescription.Builder builder = new GestureDescription.Builder();
builder.addStroke(new GestureDescription.StrokeDescription(path, startOffset, duration));
boolean result = svc.dispatchGesture(builder.build(), null, null);
if (!result) {
Log.w(TAG, "dispatchGesture failed");
}
}
}

View File

@@ -19,10 +19,20 @@ public class InputCommandHandler {
private final InputExecutor inputExecutor; private final InputExecutor inputExecutor;
public InputCommandHandler(int screenWidth, int screenHeight) { public InputCommandHandler(int screenWidth, int screenHeight) {
this(screenWidth, screenHeight, new SystemInputUtils());
}
/**
* 允许调用方根据权限/环境选择具体的输入执行器。
*
* @param screenWidth 真实屏幕宽度(用于坐标映射)
* @param screenHeight 真实屏幕高度(用于坐标映射)
* @param inputExecutor 输入执行器(系统注入 / Shell / 无障碍 等)
*/
public InputCommandHandler(int screenWidth, int screenHeight, InputExecutor inputExecutor) {
this.screenWidth = screenWidth; this.screenWidth = screenWidth;
this.screenHeight = screenHeight; this.screenHeight = screenHeight;
// 使用系统签名权限下的 SystemInputUtils 进行事件注入 this.inputExecutor = inputExecutor;
this.inputExecutor = new SystemInputUtils();
} }
public void handleCommand(byte[] data) { public void handleCommand(byte[] data) {

View File

@@ -28,7 +28,13 @@ import androidx.core.app.NotificationCompat;
import com.ttstd.controlled.R; import com.ttstd.controlled.R;
import com.ttstd.controlled.activity.connection.ConnectionRequestActivity; import com.ttstd.controlled.activity.connection.ConnectionRequestActivity;
import com.ttstd.controlled.activity.main.MainActivity; import com.ttstd.controlled.activity.main.MainActivity;
import com.ttstd.controlled.accessibility.KeyboardAccessibilityService;
import com.ttstd.controlled.input.AccessibilityInputUtils;
import com.ttstd.controlled.input.InputCommandHandler; import com.ttstd.controlled.input.InputCommandHandler;
import com.ttstd.controlled.input.InputExecutor;
import com.ttstd.controlled.input.ShellInputUtils;
import com.ttstd.controlled.input.SystemInputUtils;
import com.ttstd.controlled.utils.SignatureUtils;
import com.ttstd.controlled.signaling.SignalMessage; import com.ttstd.controlled.signaling.SignalMessage;
import com.ttstd.controlled.signaling.WebSocketClient; import com.ttstd.controlled.signaling.WebSocketClient;
import com.ttstd.controlled.webrtc.WebRtcClient; import com.ttstd.controlled.webrtc.WebRtcClient;
@@ -153,13 +159,32 @@ public class ScreenCaptureService extends Service {
Log.i(TAG, "Capture resolution: " + captureWidth + "x" + captureHeight + " @ " + fps + "fps"); Log.i(TAG, "Capture resolution: " + captureWidth + "x" + captureHeight + " @ " + fps + "fps");
// 输入处理器必须使用真实分辨率进行坐标映射 // 输入处理器必须使用真实分辨率进行坐标映射
inputHandler = new InputCommandHandler(realWidth, realHeight); inputHandler = new InputCommandHandler(realWidth, realHeight, createInputExecutor());
// 开启屏幕捕获,使用计算出的 capture 分辨率 // 开启屏幕捕获,使用计算出的 capture 分辨率
startScreenCapture(resultCode, resultData, serverUrl, deviceId, captureWidth, captureHeight, fps); startScreenCapture(resultCode, resultData, serverUrl, deviceId, captureWidth, captureHeight, fps);
return START_NOT_STICKY; return START_NOT_STICKY;
} }
/**
* 根据当前应用权限/环境选择最合适的输入执行器:
* 1. 系统签名应用优先使用隐藏 API 注入(支持精确触摸与任意按键);
* 2. 已开启无障碍服务时使用 AccessibilityInputUtils无需 root/系统签名);
* 3. 兜底使用 root shell 方式。
*/
private InputExecutor createInputExecutor() {
if (SignatureUtils.isSystemSignature(this) || SignatureUtils.isSharedSystemUid(this)) {
Log.i(TAG, "Input executor: SystemInputUtils (system signature)");
return new SystemInputUtils();
}
if (KeyboardAccessibilityService.getInstance() != null) {
Log.i(TAG, "Input executor: AccessibilityInputUtils (accessibility enabled)");
return new AccessibilityInputUtils();
}
Log.i(TAG, "Input executor: ShellInputUtils (fallback)");
return new ShellInputUtils();
}
private final IBinder binder = new LocalBinder(); private final IBinder binder = new LocalBinder();
public class LocalBinder extends Binder { public class LocalBinder extends Binder {

View File

@@ -4,6 +4,11 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
tools:context=".activity.main.MainActivity"> tools:context=".activity.main.MainActivity">
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
@@ -74,8 +79,9 @@
<org.webrtc.SurfaceViewRenderer <org.webrtc.SurfaceViewRenderer
android:id="@+id/local_view" android:id="@+id/local_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="500dp"
android:layout_weight="1" /> android:layout_weight="1" />
</LinearLayout> </LinearLayout>
</ScrollView>
</layout> </layout>

View File

@@ -7,4 +7,10 @@
<string name="connection_reject">拒绝</string> <string name="connection_reject">拒绝</string>
<string name="signal_disconnected_message">与信令服务器断开,屏幕共享已停止</string> <string name="signal_disconnected_message">与信令服务器断开,屏幕共享已停止</string>
<string name="controller_disconnected_message">控制端(%s已断开远程控制</string> <string name="controller_disconnected_message">控制端(%s已断开远程控制</string>
<string name="accessibility_service_description">用于远程控制时模拟键盘与触摸输入。开启后,控制端可通过本服务向本机发送返回、主页、音量等系统按键及触摸手势。</string>
<string name="accessibility_hint">未使用系统签名:如需在本机模拟键盘/触摸,请在系统设置中开启本应用的无障碍服务。</string>
<string name="accessibility_dialog_title">需要开启无障碍服务</string>
<string name="accessibility_dialog_message">当前应用未使用系统签名,也未获取 Root 权限。如需在远程控制时模拟键盘与触摸输入,请前往系统设置开启本应用的无障碍服务。</string>
<string name="accessibility_dialog_go">去开启</string>
<string name="accessibility_dialog_cancel">取消</string>
</resources> </resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/accessibility_service_description"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:notificationTimeout="100"
android:canPerformGestures="true"
android:canRetrieveWindowContent="false" />

View File

@@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:android_id/android_id.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@@ -8,15 +9,16 @@ import 'package:uuid/uuid.dart';
/// 与 WebRTCControlled 的 DeviceUtils 对应,但控制端没有系统签名, /// 与 WebRTCControlled 的 DeviceUtils 对应,但控制端没有系统签名,
/// 因此无法稳定获取真实的硬件序列号,这里采用「不要求准确」的兜底方案: /// 因此无法稳定获取真实的硬件序列号,这里采用「不要求准确」的兜底方案:
/// 1. 优先使用官方插件([DeviceInfoPlugin])提供的 serialNumber /// 1. 优先使用官方插件([DeviceInfoPlugin])提供的 serialNumber
/// 2. 失败则使用 iOS 的 identifierForVendor /// 2. 如果 serialNumber 为 unknown则尝试获取 Android ID
/// 3. 失败则生成随机 UUID。 /// 3. 失败则使用 iOS 的 identifierForVendor
/// 4. 再失败则生成随机 UUID。
class DeviceUtils { class DeviceUtils {
DeviceUtils._(); DeviceUtils._();
/// 获取设备标识(适用于普通应用)。 /// 获取设备标识(适用于普通应用)。
/// ///
/// 兼容各平台Android 使用官方序列号,iOS 使用 identifierForVendor /// 兼容各平台Android 优先使用官方序列号,其次使用 Android ID
/// 均不可用时回退到随机 UUID。 /// iOS 使用 identifierForVendor均不可用时回退到随机 UUID。
static Future<String> getSerialNumber() async { static Future<String> getSerialNumber() async {
const uuid = Uuid(); const uuid = Uuid();
try { try {
@@ -27,6 +29,13 @@ class DeviceUtils {
if (serial.isNotEmpty && serial.toLowerCase() != 'unknown') { if (serial.isNotEmpty && serial.toLowerCase() != 'unknown') {
return serial; return serial;
} }
// 如果 serialNumber 获取失败,尝试获取 Android ID
const androidIdPlugin = AndroidId();
final androidId = await androidIdPlugin.getId();
if (androidId != null && androidId.isNotEmpty) {
return androidId;
}
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
final iosInfo = await deviceInfo.iosInfo; final iosInfo = await deviceInfo.iosInfo;
final id = iosInfo.identifierForVendor; final id = iosInfo.identifierForVendor;

View File

@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
android_id:
dependency: "direct main"
description:
name: android_id
sha256: "543bbfcf316de69d3ac36601d74eeaacd0248178a2671b00ad30d09f35bd3581"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.5.2+1"
args: args:
dependency: transitive dependency: transitive
description: description:

View File

@@ -48,6 +48,7 @@ dependencies:
device_info_plus: ^11.0.0 device_info_plus: ^11.0.0
protobuf: ^6.0.0 protobuf: ^6.0.0
fixnum: ^1.1.1 fixnum: ^1.1.1
android_id: ^0.5.2+1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: