Android知识程序员Andorid的好东西

AccessibilityService+OpenCV实现微信6

2019-01-05  本文已影响74人  LnJan

引言

提起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>

我们必须注意:任何一个信息配置错误,都会使该服务无反应

3、配置服务参数

配置服务参数是指:配置用来接受指定类型的事件,监听指定package,检索窗口内容,获取事件类型的时间等等。其配置服务参数有两种方法:

这里我使用的是第一种方法:
在项目中增加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"/>

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、原理分析

首先我们可以通过两种方式监控红包消息通知:监控微信悬浮框和监控屏幕状态变化
拆红包的流程可分为下列三种情况:

悬浮框通知

屏幕状态变化

[聊天页面]拆红包流程

                    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);
                    }

2、注意事项

3、代码实现

聊天列表查找[微信红包]字样的新消息的实现

上面提到由于新版微信重写了TextView所以通过AccessibilityService基本上是获取不了任何聊天内容的消息了,相应的findAccessibilityNodeInfosByText方法也没有用了。
这里我的解决方法是通过截屏剪裁含有未读消息信息的区域,然后通过OpenCv特征点匹配的方式来确认哪些未读消息是包含[微信红包]的。具体实现如下:

申请录屏权限

    /**
     * 申请屏幕录取权限
     */
    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他特征点匹配方法汇总。我使用的是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会原来越难,所以在以后的版本中可能需要用类似的方法去识别微信红包了

抢红包服务核心实现代码

    @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

    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版本还没研究。如果哪位大神有更好的方法还望不吝赐教。
项目源码下载
安装包下载

上一篇下一篇

猜你喜欢

热点阅读