前言
本文将介绍一种基于root权限实现Android手机全局触摸事件监听的方式,并实现了一个系统。在开启该功能后,系统作为service运行于后台,并可以持续手机用户的触屏数据,包括屏幕坐标、接触面积以及触摸事件类型(down、up、move),类似于Android本身的MotionEvent。
本系统开发的初衷为:通过采集触摸数据通过机器学习验证当前手机使用者是否为合法用户,并模拟这一功能被定制进安卓系统后的使用情况。
本方法仅作为学习交流使用,请确保在符合法律规定的条件下使用本方法,使用本方法产生的后果均自行承担!
如果你有更好的实现方法,欢迎交流讨论
手机:魅蓝Note3
系统:Flyme(基于Android 7)—— MIN API 24
编程语言:Kotlin
AS版本:Android Studio Flamingo | 2022.2.1
可能的思路
-
通过创建一个透明的悬浮窗实现全局触摸数据采集。主要通过WindowManager实现。这一方法看起来可行,但实际实现时会发现:悬浮窗运行时,会拦截所有触摸数据。也就是说,悬浮窗存在时,用户的操作无法穿透悬浮窗而到达下面的控件,这样用户无法正常操作手机。而如果设置悬浮窗不作为焦点,则悬浮窗无法接收到任何触摸数据。
-
通过辅助功能实现。Android提供了AccessibilityService类,用于开发者编写无障碍app,可以通过AccessibilityServiceInfo中的eventType字段注册想要监听的事件类型(或者在xml中静态注册),来在该事件发生时获取到AccessibilityEvent实例,并做相应处理。
但该方法实际上在运行时,收集不到任何数据。原因似乎是需要进入无障碍模式后才能收集到数据,但Flyme进入无障碍模式的方法似乎和原生安卓有所不同。并且,在AccessibilityEvent中,只提供了按下和离开两种触摸事件类型,无法满足全局触摸数据采集的需求。
其实细想就会发现,想要以官方提供的API来全局监听触摸事件本就是不可能的,因为Android不可能允许一个app可以在后台运行的同时还可以收集到用户操作其他界面时的触摸数据,这会造成严重的安全问题,虽然在早期的Android版本(安卓4以前)似乎可行。因此我们必须另寻他法,在查阅了很多资料后,我终于找到了一个另辟蹊径的方法。
实际实现方法
getevent 调试命令
首先我们需要思考,我们平时编程中使用的view、activity等组件,都是从哪里获得的交互事件呢?来自于系统提供的API?但系统本身又是从哪里获得的数据呢?如果层层溯源,那么这些数据只能来自于用户操作硬件时产生的数据,因此只要能够获取硬件设备的输入,我们就可以获得触摸事件。
我们知道android本身是基于Linux内核构建的,getevent命令就是通过直接读取设备文件的方式来获取硬件输入数据。这种方法是最原始的,但也是最有效的。因为android本质上就是运行在Linux系统上的,因此我们直接读取设备文件就可以获取到来自硬件的数据,这一方法实际上也是android系统本身在做的工作。android从设备文件中读取数据,并将这些数据分发给各个控件,从而使得这些控件能够根据编写好的代码做出响应。
而Android提供了getevent调试命令,用于监视设备的输入。
官网文档:getevent
初窥getevent
我们首先在adb模式下运行这一命令,进行探究。
我们可以使用ls
命令列出当前手机中所有的设备文件。设备文件存在于/dev/input
目录下。
如果我们直接运行getevent,那么会输出所有设备文件产生的数据。
并且event7设备在不停地输出数据
注意一个坑:这里命令行会时不时输出“FLYME_HIPS_DEBUG:30,0”
,这会对我们后续读命令行造成干扰。
不知道其他手机会不会有这个问题。
接下来我们需要找到哪个是屏幕所对应的设备文件。
我们在黑屏和亮屏状态下,点击按钮或点击屏幕,并观察哪个设备文件产生了输出,经过实验发现:
event4对应轻触home键
event5对应电源键和音量键以及按下home键
event8对应屏幕的设备文件。
使用命令单独查看event8的输出,其中 -t 为输出时间戳、-l 为显示具体文本信息…,具体的命令选项可以百度。
这里我们使用getevent -lt /dev/input/event8
命令,便于阅读,同时也便于后续编写代码。
点击一次屏幕,可以看到如下输出
其输出格式与Linux中定义的大致相同,详细介绍可以看这篇文章Linux系统struct input_event结构体分类型(鼠标、键盘、触屏)详解与例子
其中第一列为时间戳,第二列为事件类型,第三列为该事件的具体类别,第四列为该事件的值。
我们从第一行开始,逐行分析,可以看到,在一次点击中:
- 首先输出了 EV_KEY 类型的事件,其具体类型为 BTN_TOUCH,代表我们的手指接触到屏幕,其值为 DOWN。
- 随后跟着的是多个 EV_ABS 类型的事件,从上向下依次为:ABS_MT_TRACKING_ID(手指ID)、ABS_MT_TOUCH_MAJOR(接触面积)、ABS_MT_POSITION_X(x轴坐标)、ABS_MT_POSITION_Y(y轴坐标)
- 最后是一个 EV_SYN 类型的事件,具体类型为SYN_REPORT,标志单次事件的结束
- 然后又是一个触摸事件,但该事件中仅有触摸面积一个值。
- 最后是一个 EV_KEY 类型的事件,其值为UP,代表手指离开屏幕。同时本次事件中还包含了ABS_MT_TRACKING_ID,但值为ffffffff,标志着多点追踪的结束。同样以SYN_REPORT,标志本次事件的结束。
通过不断测试和观察,我们可以总结出getevent命令的输出有以下几个特点:
- 触摸动作以BTN_TOUCH DOWN开始
- 触摸动作以BTN_TOUCH UP结束
- 两次标志中间的事件均为本次触摸产生的事件
- 单个触摸事件以SYN REPROT为结束标识
- 多点触控时,触摸事件的ABS_MT_TRACKING_ID不同,对应不同手指的触摸数据
- 在新的触摸事件中,会省略输出与上一个触摸事件中相同的字段。也就是说,如果我们横向滑动屏幕,那么手指只有x坐标变化,那么此时getevent输出的一系列事件中,仅会输出x的坐标,而不会出现y的坐标,y坐标仅在第一次接触屏幕时的第一个触摸事件中出现。
- ABS_MT_TOUCH_MAJOR类型官方解释为:接触面积的直径。也就是将触摸形状近似为一个圆,单位未知。根据这一数据可以计算出接触面积的大小。但经过实际测试:在app中调用官方API——MotionEvent中的getsize获取的接触面积就是ABS_MT_TOUCH_MAJOR的值,说明官方并没有根据该值进一步计算得到实际面积,而是直接将该值当作了接触面积。
至此,我们已经基本摸清了getevent的输出模式,接下来我们的思路就确定了:
在代码中执行这一命令,并读取命令行,对数据进行处理,这样我们就可以获得触摸事件了。
其实还有另一个思路:
getevent本身是一个C程序,其调用的是Linux的接口。我简单看了一下源码的实现逻辑。其主要工作就是解析传入的参数,打开相应设备文件,然后循环读取设备文件,将其中的数据读入一个input_event
结构体中,该结构体就是Linux中存储设备文件输出的结构体,并将其中的数据按照参数设置的格式进行输出。
那么我们可以仿照getevent的实现方式,使用C语言魔改出一个自己的getevent,也许可以成功。但可能会面临诸如:权限问题、混合语言编程以及下面提到的竞争问题等一系列问题,所以最终我还是选择了使用现成的getevent的方式。
题外话-同时读取设备文件是否会产生竞争?
安卓系统本身应该是在不断监视设备文件的,否则无法及时对用户操作做出响应。那么如果我们同时调用getevent,是否会导致两者同时读取设备文件,从而造成两方都无法完整地读取到全部触摸事件呢?
据我浅薄的了解,Linux的设备文件应该是与普通文件不同的,在读取后,其中的数据应该会消失,是类似于管道文件的一种特殊文件。如果是这样的话,那么调用getevent按理来说会产生竞争才对。但实际上经过测试,并没有产生竞争,getevent每次都可以读取到完整的一次触摸动作,并且可以正常操作手机。
不知道是否有大佬可以解答这个问题。
具体实现
注:由于本项目还有其他功能,如与服务器通信等。因此需要设计额外的流程,会有很多看起来冗余的实现(也可能是确实多余,毕竟刚接触安卓不久,水平有限)。如果只想关注如何获取触摸数据,那么可以略过架构设计和流程控制部分。
架构设计
- 全局监听这一功能需要以Service来运行,确保app在后台也能正常读取数据, 以下称为GlobalDetector。
- 在我们的自定义Service中,执行命令行、读取命令行的部分都需要在子线程中进行,避免主线程阻塞。
- 线程间通信使用 MessageHandler 完成。
- Activity 与 Service 使用各自的 MessageHandler 完成通信和控制。这里没有使用Binder实现,因为在本项目中,全局监听的Service——GlobalDetector 继承自代码中已经存在过的 ClientService,同时还负责与服务器通信。原ClientService中已经自定义过一个binder,该binder用于控制service何时向服务器发送数据。如果要修改binder,那两个service都要改。所以这里直接从简,使用 MessageHandler与外部进行通信, 而GlobalDetector直接调内部binder中的方法和服务器通信。(这不重要,不理解可以跳过)
流程控制
- GlobalDetector在 onBind 中设置binder中的 MessageHandler 为自己的 Handler,并返回binder对象。
- 外部的Activity在 ServiceConnection实例中的 onServiceConnected 方法中,从binder对象中获取 Handler。如此一来,Activity中就获得了GlobalDetector的 Handler,可以控制 GlobalDetector 的行为。
- 同时,Activity将binder中的 Handler 设置为自己的 Handler,这样两个Service都可以通过使用binder中的 Handler 与外部的Activity通信了。
- 在主线程THREAD中的 Activity 调用 startGlobalDetect 方法开启子线程Thread,开启全局监听。
- 在Thread中,再次开启子线程thread,调用executeCommand方法,使用getevent获取数据。
- thread中将数据通过touchEventMsgHandler发送至Thread中。
- Thread中接收返回的数据并处理,随后调用communicationBinder.sendVerifyData方法发送验证数据
- 操作7同样会开启子线程child_thread,并将验证结果通过 binder 中的 Handler 传回。此 Handler 即为Activity在初始化时设置的 Handler,因此 Activity 会接收到 binder 发送的消息,并做出响应。
读取数据
这一部分代码封装为函数 startGetEvent。
-
首先将需要执行的命令构造成一个ArrayList,并使用该arraylist初始化 ProcessBuilder。
-
随后将错误流重定向到正常输出流中,否则会遇到没有输出的情况。
-
然后调用start方法执行这一命令。这里调用start方法后,相当于开启了一个shell进程,使用变量process记录。因此在调用start方法后,阻塞的并不是当前线程。
-
最后调用readFromStream函数取读取命令行输出
private fun startGetEvent()
{
Log.i(tag, "startGetEvent")
val command = ArrayList<String>()
// su 0 getevent -lt /dev/input/event8
command.add("su")
command.add("0")
command.add("getevent")
command.add("-lt")
command.add("/dev/input/event8")
val processBuilder = ProcessBuilder(command)
processBuilder.redirectErrorStream(true)
process = processBuilder.start()
thread {
readFromStream()
}
/*Log.i(tag, "after start read thread")
val msg = Message.obtain()
msg.what = MessageType.GLOBAL_DECTECT_ON.ordinal
globalDetectorMsgHandler.sendMessage(msg)*/
//process.waitFor()
Log.i(tag, "startGetEvent's thread terminated")
}
函数readFromStream实现如下:
- 首先获得shell进程的inputStream
- 使用BufferedReader进行读取,每次读一行
- 将读取到的信息通过touchEventMsgHandler发送,一行对应一个消息。
private fun readFromStream()
{
val inputStream = process.inputStream
val bufferedReader = BufferedReader(InputStreamReader(inputStream))
try {
while (true) {
val line = bufferedReader.readLine() ?: break // Exit loop when null line is encountered
val msg = Message.obtain()
msg.what = MessageType.EVENT_DATA.ordinal
msg.obj = line
touchEventMsgHandler.sendMessage(msg)
}
} catch (e: IOException) {
// Handle the exception, e.g., log an error message
Log.i(tag, "${e.toString()}")
} finally {
// Close the BufferedReader and any other necessary cleanup
bufferedReader.close()
}
Log.i(tag, "readFromStream thread terminated")
}
这里的一个天坑就是:到底如何通过命令行执行getevent -lt /dev/input/event8
?
我们在adb中可以直接执行这句话,是因为adb调试本身就具有较高的权限。
但如果在代码中执行该命令,是需要root权限的。也就是说,我们需要先执行su
命令。
然而这个ProcessBuilder非常坑爹,其构造参数ArrayList的每个元素并不是对应命令的每一行,而是对应一个空格。因此可以看到在startGetEvent函数中的构造方式:同一命令中的选项字段,需要作为单独的元素加入至arraylist中。这就导致我们无法在一个shell进程中一次执行两条命令。如果先执行su,那么getevent就和su不在一个shell中,则getevent无法运行。
那么我们怎样才能在一个shell中同时执行两条命令呢?
我查了很多帖子,尝试了无数种写法,最终发现可以通过添加0来换行
处理数据
未完待续…