01月25, 2017

如何通过Xposed框架获取点击的文字

简介

我们模仿锤子制作的Bigbang应用,通过辅助服务基本上实现了在微信、QQ等聊天应用中快速取词,在其他应用中也能用其他方式补足。虽然由于辅助服务的限制,无法做到在锤子手机中那么方便,但也还算不错了。

最遗憾的是辅助服务在一些系统上(小米、华为等)会容易被自动关闭,导致用户经常抱怨,这是因为这些系统中清理后台的时候,会把应用标记为STOPPED,也就是停止使用的,所以导致了一些权限被回收。后来有用户建议我们使用xposed框架来实现取词,于是我就借此机会学习了一下鼎鼎大名的xposed框架。这篇就是关于如何使用xposed框架实现在所以应用中通过点击获取文字的。

Xposed 是什么?

Xposed是一个框架,它可以在不修改APK的情况下影响程序运行或修改系统服务,基于它可以制作出许多功能强大的模块,且在功能不冲突的情况下同时运作。这些模块本身也是以APK的形式提供,可以实现五花八门的功能,比如全自动抢红包、模拟定位、将微信改成材料设计风格等等。

但是Xposed并不是所有手机都能运行的,目前支持7.0以下的手机系统,而且有些厂商由于修改了系统的虚拟机实现,所以也可能造成Xposed框架不兼容。

对于已经安装了Xposed框架的手机,其插件的执行是不需要root权限的,但是,但是,但是普通的手机刷入Xposed框架需要root。为什么需要root权限呢?首先必须了解一下它的工作原理:

Xposed的原理简介

Android 系统在启动时,有一个名字叫做“Zygote”的进程,它是android 运行时环境的核心,从它的名字(中文含义——受精卵)就能看出其重要性,所有的其他app进程都是fork这个 Zygote进程产生的。这个Zygote是如何启动呢?答案是在手机启动时,执行了/init.rc脚本,最后还会执行/system/bin/app_process(加载需要的类以及关联初始方法)。这里就是Xposed框架执行的地方,当你安装了Xposed框架,一个 extended app_process被拷贝到来了 /system/bin,然后这个'extended startup process' 就会把 XposedBridge.jar加载到运行时环境。这样我们就可以在虚拟机启动之前,甚至是在Zygote的main方法被执行之前做一些爱做的事(捂脸,其实就是加载插件)。此时我们的插件被执行,就是Zygote进程的一部分,所以可以直接获取到应用的上下文Context,然后做很多超出想象的事情——对于任何一个app ,我们都可以hook或者替换掉其中的类或方法或对象。其实我一直不太明白应该怎么解释hook,有种只可意会不可言传的感觉,不过你看完这篇估计就懂了。

Xposed很厉害有木有!

知道这些以后,我们便可以开发自己的插件,官方教程点这里Xposed官方教程

创建Xposed模块

首先需要知道,Xposed模块是以APK的格式提供的,本身也是需要安装到手机上的,也像普通应用一样可以启动,只是因为APK中包含了一些声明,被Xposed框架检测到了,所以同时也可以以Xposed模块的方式来进行hook操作。那么这些声明是什么呢?

在AndroidManifest.xml中添加下面的声明,meta-data中的内容分别用于声明是否为插件,插件的描述和兼容的最低Xposed版本。

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.shang.xposed">

    <application
     >
        <activity android:name=".setting.XposedAppManagerActivity"
            android:theme="@style/BaseAppTheme">

        </activity>

        <meta-data
            android:name="xposedmodule"
            android:value="true" />
        <meta-data
            android:name="xposeddescription"
            android:value="支持在任意APP中点击文字进行分词,可以对每个应用选择单击、双击或者长按。建议在设置中将【点击悬浮球触发BigBang】打开,以减少误触发。" />
        <meta-data
            android:name="xposedminversion"
            android:value="30" />
    </application>

</manifest>

在工程的assets目录下新建文件xposed_init,内容为:

com.shang.xposed.XposedBigBang

很明显这是一个类的全限定名,这个类就是进行hook操作的类

在build.gradle中添加依赖:

dependencies {
    provided 'de.robv.android.xposed:api:82'
}

Xposed框架是预先安装到你的手机中的,所以我们只需要以provided的方式依赖就行了,82是版本号,是本文写作时的最新版本,该用什么版本可以看这里。一般来说xposedminversion的值应该与这里相等,但是如果你能保证你使用的API并不是新版本加入的,则可以将xposedminversion写低一些。

创建类com.shang.xposed.XposedBigBang,内容如下:

package com.shang.xposed;

public class XposedBigBang implements IXposedHookLoadPackage {
    @Override
    public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
    }
}

关闭Instant Run (File -> Settings -> Build, Execution, Deployment -> Instant Run)

完成以上操作以后,安装完程序,你就会在Xposed installer中看到你安装的应用,如下图:

Xposed installer的模块列表

勾选以后重启就可以生效了。当然目前什么功能都没有实现,所以还是先别重启了,继续看。

如何实现点击文字触发分词

既然前面已经说过了,Xposed框架可以hook方法,所以很直觉就会想到:只要将TextView的OnClickListener替换成我们的,不就能拿到点击事件了吗。直接看代码:

package com.shang.xposed;

public class XposedBigBang implements IXposedHookLoadPackage {
    private final TouchEventHandler mTouchHandler = new TouchEventHandler();
    @Override
    public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
        findAndHookMethod(View.class, "setOnClickListener", View.OnClickListener.class, new ViewOnClickListenerHooker(loadPackageParam.packageName,type));                
    }

    private class ViewOnClickListenerHooker extends XC_MethodHook {

        private final String packageName;

        public ViewOnClickListenerHooker(String packageName,int type) {
            this.packageName = packageName;
            setClickTypeToTouchHandler(type);
        }

        @Override
        protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
            super.beforeHookedMethod(param);
            View view = (View) param.thisObject;
            final View.OnClickListener listener = (View.OnClickListener) param.args[0];
            if (isKeyBoardOrLauncher(view.getContext(), packageName))
                return;
            View.OnClickListener newListener=new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    mTouchHandler.hookOnClickListener(v,mFilters);
                    if (listener==null){
                        return ;
                    }else {
                        listener.onClick(v);
                    }
                }
            };
            param.args[0]=newListener;
        }
    }
}

代码的方法名就是最好的注释,这里是hook了setOnClickListener,并将传入的OnClickListener替换成我们的,在我们的Listener中再调用原来的Listener。

不过这种方法只能获取设置了OnClickListener的View上的点击,如果没有设置OnClickListener则无法获取,所以我们还需要hook住dispatchTouchEvent方法。将下面代码添加到相应位置:

findAndHookMethod(View.class, "dispatchTouchEvent", MotionEvent.class, new ViewTouchEvent(loadPackageParam.packageName,type));

private class ViewTouchEvent extends XC_MethodHook {

    private final String packageName;
    Class viewRootImplClass;
    public ViewTouchEvent(String packageName,int type) {
        this.packageName = packageName;
        try {
            viewRootImplClass = this.getClass().getClassLoader().loadClass("android.view.ViewRootImpl");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        setClickTypeToTouchHandler(type);
    }


    @Override
    protected void afterHookedMethod(MethodHookParam param) throws Throwable {
        super.afterHookedMethod(param);
        View view = (View) param.thisObject;
        if (isKeyBoardOrLauncher(view.getContext(), packageName))
            return;
        MotionEvent event = (MotionEvent) param.args[0];
        if ((Boolean) param.getResult() || view.getParent()==null || (viewRootImplClass.isInstance(view.getParent()) )) {
            mTouchHandler.hookTouchEvent(view, event, mFilters, true, appXSP.getInt(SP_DOBLUE_CLICK, 1000));
        }
    }
}

通过上面代码的最后几行能看到,我们只对消费了这个MotionEvent的view调用mTouchHandler.hookTouchEvent(),其内容如下:


public boolean hookTouchEvent(View v, MotionEvent event, final List<Filter> filters, boolean needVerify, int anInt) {
    hasTriggerLongClick=false;
    hasTriggerClick=false;
    hasTriggerDoubleClick=false;
    if (handler==null){
        handler=new Handler(Looper.getMainLooper());
    }
    if (gestureDetector==null){
        gestureDetector=new GestureDetector(v.getContext(),new GestureDetector.SimpleOnGestureListener(){
            ...

            @Override
            public boolean onSingleTapConfirmed(MotionEvent e) {
                Log.e(TAG,"gestureDetector onSingleTapConfirmed");
                if (!useClick){
                    return false;
                }
                if (mCurrentView==null){
                    return false;
                }                    
                if (!hasTriggerClick){
                    hasTriggerClick=true;
                    handler.post(new Runnable() {
                        @Override
                        public void run() {
                            String text = getTextFromView(mCurrentView, filters);
                            Log.e(TAG, "onSingleTapConfirmed text=" + text);
                            longPressedRunnable.setText(text);
                            longPressedRunnable.run();
                        }
                    });
                }
                return super.onSingleTapConfirmed(e);
            }

        });
    }
    gestureDetector.onTouchEvent(event);
    BIG_BANG_RESPONSE_TIME = anInt;
    boolean handle = false;
//        Log.e(TAG,"hookTouchEvent event:"+event);
    if (event.getAction() == MotionEvent.ACTION_DOWN){
        View targetTextView = getTargetTextView(v, event,filters);
        mCurrentView=targetTextView;
    }
    float currentX = event.getRawX();
    float currentY = event.getRawY();

    float x =longPressedRunnable.getX();
    float y=longPressedRunnable.getY();
    if (mScaledTouchSlop==0) {
        mScaledTouchSlop = ViewConfiguration.get(v.getContext()).getScaledTouchSlop();
    }        
    return handle;
}


private View getTargetTextView(View view, MotionEvent event, List<Filter> filters) {
    if (isOnTouchRect(view, event)) {
        if (view instanceof ViewGroup) {
            getTopSortedChildren((ViewGroup) view, topmostChildList);
            final int childCount = topmostChildList.size();
            for (int i = 0; i < childCount; i++) {
                View child = topmostChildList.get(i);
                if (isOnTouchRect(child, event)) {
                    if (child instanceof ViewGroup) {
                        return getTargetTextView(child, event, filters);
                    } else if (isValid(filters, child))
                        return child;
                }
            }
        } else {
            if (isOnTouchRect(view, event) && isValid(filters, view)) {
                return view;
            }
        }

    }
    return null;
}


private boolean isOnTouchRect(View view, MotionEvent event) {
    int rawX = (int) event.getRawX();
    int rawY = (int) event.getRawY();
    int[] xy = new int[2];
    view.getLocationOnScreen(xy);
    Rect rect = new Rect();
    rect.set(xy[0], xy[1], xy[0] + view.getWidth(), xy[1] + view.getHeight());
    return rect.contains(rawX, rawY);
}


private void getTopSortedChildren(ViewGroup viewGroup, List<View> out) {
    out.clear();
    //todo 因为系统的限制不能再非ViewGroup 中调用 isChildrenDrawingOrderEnabled 和 isChildrenDrawingOrderEnabled 方法。所以这里暂时注释掉了
//        final boolean useCustomOrder = viewGroup.isChildrenDrawingOrderEnabled();
    final int childCount = viewGroup.getChildCount();
    for (int i = childCount - 1; i >= 0; i--) {
//             int childIndex = useCustomOrder ? viewGroup.isChildrenDrawingOrderEnabled(childCount, i) : i;
        int childIndex = i;
        final View child = viewGroup.getChildAt(childIndex);
        if (child.getVisibility() == View.VISIBLE) {
            out.add(child);
        }
    }

    if (TOP_SORTED_CHILDREN_COMPARATOR != null) {
        Collections.sort(out, TOP_SORTED_CHILDREN_COMPARATOR);
    }
}


private boolean isValid(List<Filter> filters, View view) {
    return (view instanceof TextView )&& !(view instanceof EditText);
}

这块代码稍微有点多,不过逻辑不复杂,就是在MotionEvent.ACTION_DOWN的时候,拿到当前点击位置的View,并判断是不是TextView,然后通过GestureDetector来判断是不是单击操作,最后触发点击后的逻辑。

你可能从代码中看出来了以下几点:

  1. 在setOnClickListener和dispatchTouchEvent的hook中用的是用同一个TouchEventHandler 进行处理的,而且用到了hasTriggerClick变量来标记,这是为了便于控制点击事件的触发,以防一次点击触发两次;
  2. 有hasTriggerLongClick、hasTriggerDoubleClick和longPressedRunnable等命名的变量,这是因为我不但实现了单击操作触发,也实现了长按和双击触发,篇幅原因,这里就不贴长按和双击的实现方式了,详细代码可以看Bigbang工程源码
  3. 传入的List filters变量好像没用到?其实这个filters是用于针对一些应用进行定制化的,比如微信的自定义View——“com.tencent.mm.ui.widget.MMTextView”,这需要对特定应用进行反编译和分析。

从代码中看不出来的几点思考:

  1. 为什么不hook住onTouch方法呢?原因很简单,因为dispatchTouchEvent比onTouch执行得早,hook onTouch也是可以的。
  2. 为什么要在一系列判断条件成立的时候才进行操作呢?因为在hookTouchEvent方法中会去定位到当前触摸位置的View,所以其实只需要确保能被调用到hookTouchEvent方法就行了,而这一系列条件就是为了保证hookTouchEvent方法不会被同一个触摸事件反复调用,从而引起误触发。
  3. 在hook setOnClickListener时并不是只对TextView做处理,而是对点击的View进行遍历,将其中所有TextView的内容拼接出来的。而在hook dispatchTouchEvent的时候,是则是拿到点击位置所在的最小的View。原因是,setOnClickListener的View是一个整体,点击的时候会作为一个整体响应点击,而dispatchTouchEvent则不一定是整体响应的,直接取整体会导致严重的误触发现象。

源码

详细代码可以看Bigbang工程源码的XposedBigBang和TouchEventHandler类,XposedBigBang还包含了全局复制的hook,感兴趣的同学可以看这篇——使用Xposed框架实现全局复制

还需要注意的是,Bigbang工程的通过productFlavors来区分Xposed版本和普通版本的,运行代码的时候注意修改。

转载注明出处:十个雨点

本文链接:http://www.siki.space/post/gettext_clicked_through_xposed.html

-- EOF --

Comments