手记

AccessibilityService+OpenCV实现微信7.0.0抢红包插件

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_CLICKEDView被点击
TYPE_VIEW_LONG_CLICKEDView被长按
TYPE_VIEW_SELECTEDView被选中
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/gdList<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根据屏幕分辨率判断[开]按钮的位置实现拆红包的功能。以本人手机为例,按钮位置如下:
    手机屏幕的尺寸是:1920*1080,则按钮点击的横坐标为x,纵坐标为y,则x、y的范围是:
    386<x<694    1015<y<1323

    拆红包按钮.png


    计算按钮位置的代码如下:


                    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及以上实现屏幕截图

  • 微信聊天列表新消息的定位



如图:

聊天列表截图.png


从上图我们可以看到,新消息是包含一个现实未读消息数的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点击该节点打开红包,这里就不贴出代码了。



作者:LnJan
链接:https://www.jianshu.com/p/c269a1a1866b


0人推荐
随时随地看视频
慕课网APP