feat: 被控端增加无障碍检查,flutter固定获取Android_Id
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -4,78 +4,84 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
tools:context=".activity.main.MainActivity">
|
tools:context=".activity.main.MainActivity">
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
|
<ScrollView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content">
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="24dp">
|
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginBottom="24dp"
|
|
||||||
android:text="WebRTC 被控端"
|
|
||||||
android:textSize="24sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<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_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_marginBottom="16dp"
|
android:orientation="vertical"
|
||||||
android:hint="ws://175.178.213.60:8088/ws/signal"
|
android:padding="24dp">
|
||||||
android:inputType="textUri" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="设备ID:"
|
android:layout_marginBottom="24dp"
|
||||||
android:textSize="14sp" />
|
android:text="WebRTC 被控端"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<EditText
|
<TextView
|
||||||
android:id="@+id/et_device_id"
|
android:layout_width="wrap_content"
|
||||||
android:layout_width="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:text="信令服务器地址:"
|
||||||
android:layout_marginBottom="24dp"
|
android:textSize="14sp" />
|
||||||
android:hint="设备ID"
|
|
||||||
android:inputType="text"
|
|
||||||
android:text="981964879" />
|
|
||||||
|
|
||||||
<TextView
|
<EditText
|
||||||
android:id="@+id/tv_status"
|
android:id="@+id/et_server_url"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="24dp"
|
android:layout_marginBottom="16dp"
|
||||||
android:text="状态: 已停止"
|
android:hint="ws://175.178.213.60:8088/ws/signal"
|
||||||
android:textSize="16sp"
|
android:inputType="textUri" />
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<Button
|
<TextView
|
||||||
android:id="@+id/btn_start"
|
android:layout_width="wrap_content"
|
||||||
android:layout_width="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:text="设备ID:"
|
||||||
android:layout_marginBottom="8dp"
|
android:textSize="14sp" />
|
||||||
android:text="开始屏幕共享" />
|
|
||||||
|
|
||||||
<Button
|
<EditText
|
||||||
android:id="@+id/btn_stop"
|
android:id="@+id/et_device_id"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="24dp"
|
||||||
android:enabled="false"
|
android:hint="设备ID"
|
||||||
android:text="停止屏幕共享" />
|
android:inputType="text"
|
||||||
|
android:text="981964879" />
|
||||||
|
|
||||||
<org.webrtc.SurfaceViewRenderer
|
<TextView
|
||||||
android:id="@+id/local_view"
|
android:id="@+id/tv_status"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="0dp"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1" />
|
android:layout_marginBottom="24dp"
|
||||||
|
android:text="状态: 已停止"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
</LinearLayout>
|
<Button
|
||||||
|
android:id="@+id/btn_start"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:text="开始屏幕共享" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btn_stop"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:enabled="false"
|
||||||
|
android:text="停止屏幕共享" />
|
||||||
|
|
||||||
|
<org.webrtc.SurfaceViewRenderer
|
||||||
|
android:id="@+id/local_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="500dp"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
</layout>
|
</layout>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user