AccessibilityService+OpenCV实现微信6
引言
提起AccessibilityService首先想到的肯定是抢红包插件。没错,目前基本上抢红包插件分为两类:root和免root,而免root的红包插件全是基于AccessibilityService。随着AccessibilityService的广泛应用,现今已经有比较多的方法可以防御基于AccessibilityService实现的自动化插件了。有兴趣的朋友可以参考这篇文章:红包外挂史及AccessibilityService分析与防御。
本文通过AccessibilityService加上OpenCV辅助识别一些关键的特征,以此在高版本微信中实现抢红包的效果。
AccessibilityService基本用法
1、继承AccessibilityService
编写自己的Service类,必须重写onAccessibilityEvent()方法和onInterrupt()方法
public class HongbaoService extends AccessibilityService {
/**
* 当启动服务的时候就会被调用(非必须重写)
*/
@Override
protected void onServiceConnected() {
super.onServiceConnected();
}
/**
* 监听窗口变化的回调
*/
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
int eventType = event.getEventType();
//根据事件回调类型进行处理
}
/**
* 中断服务的回调
*/
@Override
public void onInterrupt() {
}
}
下面简要地介绍用到的几个AccessibilityEvent的事件类型
事件类型 | 描述 |
---|---|
TYPE_VIEW_CLICKED | View被点击 |
TYPE_VIEW_LONG_CLICKED | View被长按 |
TYPE_VIEW_SELECTED | View被选中 |
TYPE_NOTIFICATION_STATE_CHANGED | 状态栏发生变化 |
TYPE_WINDOW_CONTENT_CHANGED | 窗口内容发生变化 |
TYPE_WINDOW_STATE_CHANGED | 打开弹出窗口、菜单、对话框等的时候触发 |
2、声明服务
首先,我们需要在manifests中配置该服务信息
<service
android:name=".service.HongbaoService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessible_service_config" />
</service>
我们必须注意:任何一个信息配置错误,都会使该服务无反应
- android:label:在无障碍列表中显示该服务的名字(默认与APP名字相同)
- android:permission:需要指定BIND_ACCESSIBILITY_SERVICE权限,这是4.0以上的系统要求的
- intent-filter:这个name是固定不变的
3、配置服务参数
配置服务参数是指:配置用来接受指定类型的事件,监听指定package,检索窗口内容,获取事件类型的时间等等。其配置服务参数有两种方法:
- 方法一:安卓4.0之后可以通过meta-data标签指定xml文件进行配置
- 方法二:通过代码动态配置参数
这里我使用的是第一种方法:
在项目中增加accessible_service_config文件,配置如下:
<accessibility-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_description"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged|typeNotificationStateChanged"
android:accessibilityFeedbackType="feedbackAllMask"
android:packageNames="com.tencent.mm"
android:notificationTimeout="300"
android:settingsActivity="com.shareder.ln_jan.wechatluckymoneygetter.activities.MainActivity"
android:accessibilityFlags="flagDefault"
android:canRetrieveWindowContent="true"
android:canPerformGestures="true"/>
- accessibilityEventTypes:表示该服务对界面中的哪些变化感兴趣,即哪些事件通知,比如窗口打开,滑动,焦点变化,长按等。具体的值可以在AccessibilityEvent类中查到,如typeAllMask表示接受所有的事件通知
- accessibilityFeedbackType:表示反馈方式,比如是语音播放,还是震动
- canRetrieveWindowContent:表示该服务能否访问活动窗口中的内容。也就是如果你希望在服务中获取窗体内容,则需要设置其值为true
- description:对该无障碍功能的描述
- notificationTimeout:接受事件的时间间隔,这里我设置的是300
- packageNames:表示对该服务是用来监听哪个包的产生的事件,如"com.tencent.mm"为微信的包名
- canPerformGestures: 安卓7.0后可通过dispatchGesture实现点击屏幕的操作,如需用此方法需将canPerformGestures设置为true
4、预备知识
4.1、获取节点信息
获取了界面窗口变化后,这个时候就要获取控件的节点。整个窗口的节点本质是个树结构,通过以下操作节点信息
1、获取窗口节点(根节点)
AccessibilityNodeInfo nodeInfo = getRootInActiveWindow();
2、获取指定子节点(控件节点)
//通过文本找到对应的节点集合
List<AccessibilityNodeInfo> list = nodeInfo.findAccessibilityNodeInfosByText(text);
//通过控件ID找到对应的节点集合,如com.tencent.mm:id/gd
List<AccessibilityNodeInfo> list = nodeInfo.findAccessibilityNodeInfosByViewId(clickId);
4.2、模拟点击的方法
获取节点信息后可通过performAction方法或dispatchGesture方法产生点击屏幕的效果
1、performAction
//模拟点击
accessibilityNodeInfo.performAction(AccessibilityNodeInfo.ACTION_CLICK);
//模拟长按
accessibilityNodeInfo.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK);
这种方法在微信6.6.1或之前的版本可用次方法拆开红包
2、dispatchGesture
GestureDescription gestureDescription = builder.addStroke(new GestureDescription.StrokeDescription(path, 450, 50)).build();
dispatchGesture(gestureDescription, new GestureResultCallback() {
@Override
public void onCompleted(GestureDescription gestureDescription) {
Log.e(TAG, "onCompleted");
mPockeyOpenMutex = false;
super.onCompleted(gestureDescription);
}
@Override
public void onCancelled(GestureDescription gestureDescription) {
Log.e(TAG, "onCancelled");
mPockeyOpenMutex = false;
super.onCancelled(gestureDescription);
}
}, null);
微信6.7.3版本由于无法再通过遍历AccessibilityNodeInfo子节点找到拆红包按钮,故只能通过此方法实现模拟点击
微信抢红包原理分析及实现
1、原理分析
首先我们可以通过两种方式监控红包消息通知:监控微信悬浮框和监控屏幕状态变化
拆红包的流程可分为下列三种情况:
悬浮框通知
- 通过TYPE_NOTIFICATION_STATE_CHANGED监控状态栏变化,当出现[微信红包]时进入微信,此时会直接进入到聊天页面
屏幕状态变化
- 此时若在聊天页面则进入拆红包流程
- 若在聊天列表页面则查找未读消息中是否包含[微信红包]字眼的信息,有则点击进入聊天页面
[聊天页面]拆红包流程
-
通过findAccessibilityNodeInfosByText方法查找包含领取红包、查看红包字样的节点,找到后通过performAction点击进入拆红包页面
-
微信6.7.3版本前可通过getRootInActiveWindow获取窗口根节点,再通过遍历的方法找到唯一的一个Button节点,通过performAction点击该节点拆红包。但在6.7.3版本不再有效。故改用dispatchGesture根据屏幕分辨率判断[开]按钮的位置实现拆红包的功能。以本人手机为例,按钮位置如下:
拆红包按钮.png
手机屏幕的尺寸是:1920*1080,则按钮点击的横坐标为x,纵坐标为y,则x、y的范围是:
386<x<694 1015<y<1323
计算按钮位置的代码如下:
Path path = new Path();
if (640 == dpi) { //1440
path.moveTo(720, 1575);
} else if (320 == dpi) {//720p
path.moveTo(355, 780);
} else if (480 == dpi) {//1080p
path.moveTo(533, 1115);
} else if (440 == dpi) {//1080*2160
path.moveTo(450, 1250);
}
- 拆开红包后通过performGlobalAction(GLOBAL_ACTION_BACK)方法从[红包详情]页面返回
- 一次拆红包流程结束
流程图如下:
抢红包流程图.png
2、注意事项
- 在聊天列表中新版微信重写了TextView控件,这意味着不能通过findAccessibilityNodeInfosByText方法查找[微信红包]了
- 由于新版微信中对已领取的红包会显示红包已被领取且不可再次点击,对于自己发放的红包若红包已领完会显示红包已被领完,若未领完则一直显示查看红包。所以只有在拆开自己发的红包时才需要做防止重复打开的处理。
- 在国内第三方定制系统中出于对电量的优化,有可能限制AccessibilityService后台长时间运行。所以需要提醒用户设置后台运行权限
3、代码实现
聊天列表查找[微信红包]字样的新消息的实现
上面提到由于新版微信重写了TextView所以通过AccessibilityService基本上是获取不了任何聊天内容的消息了,相应的findAccessibilityNodeInfosByText方法也没有用了。
这里我的解决方法是通过截屏剪裁含有未读消息信息的区域,然后通过OpenCv特征点匹配的方式来确认哪些未读消息是包含[微信红包]的。具体实现如下:
-
MediaProjection实现屏幕截图
申请录屏权限
/**
* 申请屏幕录取权限
*/
private void requestScreenShot() {
startActivityForResult(
((MediaProjectionManager) this.getActivity().getSystemService("media_projection")).createScreenCaptureIntent(),
REQUEST_MEDIA_PROJECTION);
}
截取屏幕内容生成BMP
public Bitmap getScreenShotSync() {
if (!isShotterUseful()) {
return null;
}
if (mImageReader == null) {
mImageReader = ImageReader.newInstance(
getScreenWidth(),
getScreenHeight(),
PixelFormat.RGBA_8888,//此处必须和下面 buffer处理一致的格式 ,RGB_565在一些机器上出现兼容问题。
1);
}
VirtualDisplay tmpDisplay = virtualDisplay();
try{
Thread.sleep(50); //需要稍微停一下,否则截图为空
}catch (InterruptedException e){
e.printStackTrace();
}
Image img = mImageReader.acquireLatestImage();
if (img == null) {
return null;
}
int width = img.getWidth();
int height = img.getHeight();
final Image.Plane[] planes = img.getPlanes();
final ByteBuffer buffer = planes[0].getBuffer();
//每个像素的间距
int pixelStride = planes[0].getPixelStride();
//总的间距
int rowStride = planes[0].getRowStride();
int rowPadding = rowStride - pixelStride * width;
Bitmap bitmap = Bitmap.createBitmap(width + rowPadding / pixelStride, height,
Bitmap.Config.ARGB_8888);//虽然这个色彩比较费内存但是 兼容性更好
bitmap.copyPixelsFromBuffer(buffer);
bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height);
img.close();
//mImageReader.close();
tmpDisplay.release();
return bitmap;
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private VirtualDisplay virtualDisplay() {
return mMediaProjection.createVirtualDisplay("screen-mirror",
getScreenWidth(),
getScreenHeight(),
Resources.getSystem().getDisplayMetrics().densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
mImageReader.getSurface(), null, null);
}
出于对效率的考虑Bitmap一直都是在内存中操作的,并不需要输出成文件
MediaProjection的详细介绍可参考:Android 5.0及以上实现屏幕截图
-
微信聊天列表新消息的定位
从上图我们可以看到,新消息是包含一个现实未读消息数的TextView,我们可以以此来判断未读消息。消息内容则是View所以我们在AccessibilityService中无法获取其内容,但是我们可以定位到未读消息内容在屏幕中的位置。
-
OpenCv特征点匹配
关于OpenCv特征点匹配的方法有很多,有兴趣的读者可以参考这篇文章OpenCv他特征点匹配方法汇总。我使用的是ORB算法。这个算法的特点是速度快,但是在准确率和抗噪点能力上会有所欠缺。
Android接入OpenCv有三种方法:接入OpenCv的Java SDK包、封装JNI、编译OpenCv源码。其中最简单的是直接接入OpenCv的Java SDK包。下载地址
关键代码如下:
/**
* 检测输入的图像是否匹配[微信红包]的本地图像
*
* @param bmInput 输入的图像
* @param isNormalScreen 对全面屏手机截取的区域不同,特征点也会不同
* @return
*/
public boolean isPictureMatchLuckyMoney(Bitmap bmInput, boolean isNormalScreen) throws CvException {
if (!isCachePictureExist()) {
return false;
}
if (bmLocal == null) {
bmLocal = BitmapFactory.decodeFile(strMoneyPicPath);
}
Mat inputGrayMat = getGrayMat(bmInput);
Mat localGrayMat = getGrayMat(bmLocal);
//特征点提取
ORB orb = ORB.create(1000); //精度越小越准确
MatOfKeyPoint kptsInput = new MatOfKeyPoint();
MatOfKeyPoint kptsLocal = new MatOfKeyPoint();
orb.detect(inputGrayMat, kptsInput);
orb.detect(localGrayMat, kptsLocal);
//特征点描述,采用ORB默认的描述算法
Mat descInput = new Mat();
Mat descLocal = new Mat();
orb.compute(inputGrayMat, kptsInput, descInput);
orb.compute(localGrayMat, kptsLocal, descLocal);
//BFMatcher matcher = new BFMatcher(BFMatcher.BRUTEFORCE_HAMMING, false);
DescriptorMatcher matcher = DescriptorMatcher.create(DescriptorMatcher.BRUTEFORCE_HAMMING);
MatOfDMatch matchPoints = new MatOfDMatch();
//Log.e("matchoutput", "--start---");
//matcher.knnMatch(descInput,descLocal,matchPointsList,2);
try {
matcher.match(descInput, descLocal, matchPoints);
} catch (CvException ex) {
Log.e("matchoutput", ex.toString());
return false;
}
//Log.e("matchoutput", "--end---");
float min_dist = 0;
DMatch[] arrays = matchPoints.toArray();
for (int i = 0; i < descInput.rows(); ++i) {
float dist = arrays[i].distance;
if (dist < min_dist) min_dist = dist;
}
int goodMatchPointNum = 0;
//筛选特征点
float compareNum = Math.max(min_dist * 2, 30.0f);
for (int j = 0; j < descInput.rows(); j++) {
if (arrays[j].distance <= compareNum) {
goodMatchPointNum++;
}
}
Log.e("matchoutput", goodMatchPointNum + "");
if (isNormalScreen) {
return goodMatchPointNum > 10;
} else {
return goodMatchPointNum >= 7;
}
}
由于微信的字体是与系统字体有关。所以我在每次软件启动时在本地绘制一张[微信红包]的图片用于做特征点比对。当截图的特征点匹配的数目大于一定数量的时候就认为这个图片中的文字可能就是[微信红包]
这里不采用ORC文字识别的原因是文字识别速度太慢了,采用这种方法的话会快好多。
而且随着微信版本的改动,想单纯通过AccessibilityService会原来越难,所以在以后的版本中可能需要用类似的方法去识别微信红包了
抢红包服务核心实现代码
-
区分AccessibilityEvent消息类型
@Override
public void onAccessibilityEvent(AccessibilityEvent accessibilityEvent) {
//Log.e(TAG, "event recv");
Log.e(TAG, "class:" + accessibilityEvent.getClassName().toString());
if (!mGlobalMutex) {
mGlobalMutex = true;
setCurrentActivityName(accessibilityEvent);
switch (accessibilityEvent.getEventType()) {
case AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED:
handleNotificationMessage(accessibilityEvent);
break;
case AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED:
case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED:
handleScreenMessage(accessibilityEvent);
break;
default:
break;
}
mGlobalMutex = false;
}
}
TYPE_NOTIFICATION_STATE_CHANGED为状态栏通知
-
根据ActivityName区分当前状态
获取当前ActivityName
private void setCurrentActivityName(AccessibilityEvent event) {
String activitiesName = event.getClassName().toString();
currentNodeInfoName = activitiesName;
if (activitiesName.startsWith("com.tencent.mm")) {
//prevActivityName = currentActivityName;
currentActivityName = activitiesName;
Log.e(TAG, "current_name:" + event.getClassName().toString());
}
}
private static final String CHATTING_LAUNCHER_UI = "com.tencent.mm.ui.LauncherUI";
private static final String LUCKY_MONEY_RECV_UI = "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyReceiveUI";
private static final String LUCKY_MONEY_DETAIL_UI = "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyDetailUI";
CHATTING_LAUNCHER_UI 聊天列表页面或聊天页面
LUCKY_MONEY_RECV_UI 拆红包页面或红包过期页面
LUCKY_MONEY_DETAIL_UI 红包详情页面
-
根据当前状态决策处理的方法
聊天列表页面或聊天页面
区分当前实在聊天列表还是在聊天页面,如果在聊天列表则查找是否有未读消息如果在聊天页面则通过findAccessibilityNodeInfosByText查找红包。
判断方法:
/**
* 检查是否在聊天列表
*
* @return boolean
*/
private boolean isInChartList() {
boolean b = false;
AccessibilityNodeInfo root = getRootInActiveWindow();
if (root != null) {
if (root.getChildCount() > 0) {
b = root.getChild(0).getChildCount() > 1;
} else {
b = false;
}
}
return b;
}
在聊天列表:判断是否有未读消息-截屏-截取未读消息区域-查找是否有红包来了
/**
* 查找未读消息区域,如果没有则返回空列表
* @return
*/
private List<Rect> findNewsRectInScreen() {
AccessibilityNodeInfo nodeInfo = findNodeInfoByClass(getRootInActiveWindow(), LIST_VIEW_NAME);
List<Rect> resultList = new ArrayList<>();
if (nodeInfo != null) {
int chartCount = nodeInfo.getChildCount();
for (int i = 0; i < chartCount; i++) {
AccessibilityNodeInfo subChartInfo = nodeInfo.getChild(i);
if (subChartInfo != null) {
if (subChartInfo.getChildCount() > 0) { //表示是未读消息,有可能有红包
Rect outputRect = new Rect();
subChartInfo.getBoundsInScreen(outputRect);
if (!isNormalScreen) {
outputRect.top -= 30;
outputRect.bottom -= 30;
}
if (outputRect.height() == 0 || outputRect.width() == 0) {
continue;
}
outputRect.left += (int) (outputRect.width() * 0.2); //去除头像区域
outputRect.top += (int) (outputRect.height() * 0.3);
resultList.add(outputRect);
}
}
}
}
return resultList;
}
在聊天页面:查找包含[领取红包]的子节点-点击该节点-进入拆红包阶段
聊天内容页面.png
由于[领取红包]关键字是个TextView,所以可以通过findAccessibilityNodeInfosByText查找到该子节点,具体代码如下:
private void findRedpockeyAndClick(AccessibilityEvent ev) {
AccessibilityNodeInfo rootInfo = getRootInActiveWindow();
//检查领取红包和查看红包
List<AccessibilityNodeInfo> redPackeyInfoLst = null;
if (mSharedPreferences.getBoolean("pref_watch_self", false)) {
redPackeyInfoLst = getPacketNode(rootInfo, WECHAT_VIEW_OTHERS_CH, WECHAT_VIEW_SELF_CH);
} else {
redPackeyInfoLst = getPacketNode(rootInfo, WECHAT_VIEW_OTHERS_CH);
}
if (redPackeyInfoLst != null && !redPackeyInfoLst.isEmpty()) {
//redPackeyInfo.getParent().performAction(AccessibilityNodeInfo.ACTION_CLICK);
String str_filter = mSharedPreferences.getString("pref_watch_exclude_words", "");
AccessibilityNodeInfo openPackeyInfo = null;
if (str_filter.equals("")) {
openPackeyInfo = redPackeyInfoLst.get(redPackeyInfoLst.size() - 1);
} else {
String[] str_fiilter_array = str_filter.split(" +");
for (int k = redPackeyInfoLst.size() - 1; k >= 0; k--) {
AccessibilityNodeInfo tmpInfo = redPackeyInfoLst.get(k);
if (tmpInfo.getParent().getChildCount() <= 0) {
continue;
}
String strPackeyMsg = tmpInfo.getParent().getChild(0).getText().toString();
boolean b = false;
for (String filter_text : str_fiilter_array) {
if ((filter_text.length() > 0) &&
strPackeyMsg.contains(filter_text)) {
b = true;
break;
}
}
if (!b) {
openPackeyInfo = tmpInfo;
break;
}
}
}
if (openPackeyInfo != null) {
AccessibilityNodeInfo parentInfo = openPackeyInfo.getParent();
if (parentInfo != null) {
parentInfo.performAction(AccessibilityNodeInfo.ACTION_CLICK);
}
}
Log.e(TAG, "pockey find!");
}
}
这里还做了关键字过滤的处理,如果包含某些关键字则不打开该红包。做到专属红包不抢
拆红包页面或红包过期页面
拆红包代码
private void openPacket() {
DisplayMetrics metrics = getResources().getDisplayMetrics();
float dpi = metrics.densityDpi;
Log.e(TAG, "openPacket!" + dpi);
if (android.os.Build.VERSION.SDK_INT <= 23) {
//nodeInfo.performAction(AccessibilityNodeInfo.ACTION_CLICK);
Toast.makeText(MyApplication.getContext(), getString(R.string.not_support_low_level), Toast.LENGTH_SHORT).show();
} else {
if (android.os.Build.VERSION.SDK_INT > 23) {
if (!mPockeyOpenMutex) {
mPockeyOpenMutex = true;
Path path = new Path();
if (640 == dpi) { //1440
path.moveTo(720, 1575);
} else if (320 == dpi) {//720p
path.moveTo(355, 780);
} else if (480 == dpi) {//1080p
path.moveTo(533, 1115);
} else if (440 == dpi) {//1080*2160
path.moveTo(450, 1250);
}
GestureDescription.Builder builder = new GestureDescription.Builder();
try {
GestureDescription gestureDescription = builder.addStroke(new GestureDescription.StrokeDescription(path, 450, 50)).build();
dispatchGesture(gestureDescription, new GestureResultCallback() {
@Override
public void onCompleted(GestureDescription gestureDescription) {
Log.e(TAG, "onCompleted");
mPockeyOpenMutex = false;
super.onCompleted(gestureDescription);
}
@Override
public void onCancelled(GestureDescription gestureDescription) {
Log.e(TAG, "onCancelled");
mPockeyOpenMutex = false;
super.onCancelled(gestureDescription);
}
}, null);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}
这里做了一个延时自动关闭的操作,因为如果红包过期了是不会进入红包详情页面的。所以这里如果过了一定时间还没有进入红包详情页面则认为这是过期红包。自动关闭
在微信6.6.1及之前的版本是可以遍历窗口节点,查找类名为android.vew.button的方法来查找到按钮的。再通过performAction点击该节点打开红包,这里就不贴出代码了。
红包详情页面
调用performGlobalAction(GLOBAL_ACTION_BACK)返回
写在最后
这是一个根据前人的版本结合自己的一些想法创造的版本。可能不一定是最优解,但是目前可以支持到微信6.7.3版本,新出的7.0.0版本还没研究。如果哪位大神有更好的方法还望不吝赐教。
项目源码下载
安装包下载