01月15, 2017

通过辅助模式获取点击的文字

在准备实现Bigbang的功能的时候,第一个需要解决的重大问题就是——如何像在锤子手机上一样方便的取词。好在有个同事做过辅助服务相关的功能,给我们提供了一个解决方案:通过辅助服务能够获取对View的点击和长按事件,并取得View的内容。

以此为起点,我们先实现了基于辅助服务的取词(适用于QQ、微信、支付宝等),然后加入了基于复制的取词(适用于浏览器、阅读器等),再又加入了全局复制功能(适用于系统设置等无法复制的页面),最后则是加上了截图OCR(适用于其他场景)。至此,基本上涵盖了所有取词的需要。

这些取词方式我都会一一介绍,这篇先介绍如何通过辅助模式取词,效果如下图所示:

通过辅助服务实现双击触发

也可以下载全能分词体验

1. 如何使用辅助服务

首先要在AndroidManifest.xml中声明:

<service
    android:name=".component.service.BigBangMonitorService"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
    android:process=":monitor">
    <intent-filter>
        <action android:name="android.accessibilityservice.AccessibilityService" />
    </intent-filter>

    <meta-data
        android:name="android.accessibilityservice"
        android:resource="@xml/accessibility" />
</service>

然后在res/xml/文件夹下新建文件accessibility.xml,内容如下:

<?xml version="1.0" encoding="utf-8"?>
<accessibility-service
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:accessibilityEventTypes="typeViewClicked|typeViewLongClicked|typeWindowStateChanged"
    android:accessibilityFeedbackType="feedbackGeneric"
    android:accessibilityFlags="flagRetrieveInteractiveWindows"
    android:canRetrieveWindowContent="true"
    android:canRequestFilterKeyEvents ="true"
    android:notificationTimeout="10"
    android:packageNames="@null"
    android:description="@string/accessibility_des"
    android:settingsActivity="com.forfun.bigbang.SettingActivity"
/>

其中accessibilityEventTypes代表希望接收的事件类型,看名字就知道我们需要的是单击和长按,至于typeWindowStateChanged,则是在用于在切换activity时接收事件用的。

canRequestFilterKeyEvents是代表希望接收按键的事件类型,比如按音量键等,这里设置成true跟当前介绍的功能无关,而是为了用按键触发悬浮窗菜单,以后另开一篇介绍。

其他flag的含义可以参考API文档,这里就不展开说了。 最后创建BigBangMonitorService,本文用到的最重要的方法如下:

public class BigBangMonitorServiceextends AccessibilityService {   
    @Override
    public void onAccessibilityEvent(AccessibilityEvent event) {
        int type=event.getEventType();
        switch (type){
            case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED:               
            case TYPE_VIEW_CLICKED:
            case TYPE_VIEW_LONG_CLICKED:
                break;
        }
    }
    @Override
    protected void onServiceConnected() {
        super.onServiceConnected();
        setServiceInfo(mAccessibilityServiceInfo);
    }
}

其中onAccessibilityEvent很明显就是我们接收事件的回调方法。

而onServiceConnected则是在本service被设置成AccessibilityService 时的回调。什么意思呢?因为AccessibilityService 本身也是一个service,可以被start,bind,而只有当用户在辅助辅助的设置页面中开启了本程序的辅助服务时,才会被作为AccessibilityService使用,此时才会回调onServiceConnected,如下图。

开启辅助服务的界面

2. 如何获取和处理点击事件

从前面的xml中,我们就已经设置好了需要获取的事件:单击和长按。所以在用户进行操作的时候我们就会收到相应的回调,注意这里的回调是异步回调,也就是说,我们没有办法对点击事件进行任何干预,只是收到一份通知而已。

那我们怎样从这个通知中取得我们想要的信息呢?直接看代码吧:

private CharSequence mWindowClassName;
private String mCurrentPackage;
private int mCurrentType;
private Map<String,Integer> selections;//保存每个应用的包名对应的触发方式
private boolean onlyText = true;
public  int double_click_interval = 1000;
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
    int type=event.getEventType();
    switch (type){
        case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED:
            mWindowClassName = event.getClassName();
            mCurrentPackage = event.getPackageName()==null?"":event.getPackageName().toString();
            Integer selectType=selections.get(mCurrentPackage);
            mCurrentType = selectType==null?TYPE_VIEW_NONE:(selectType+1);                
            break;
        case TYPE_VIEW_CLICKED:
        case TYPE_VIEW_LONG_CLICKED:
            getText(event);
            break;

}


private synchronized void getText(AccessibilityEvent event){        
    int type=getClickType(event);
    CharSequence className = event.getClassName();
    if (mWindowClassName==null){
        return;
    }
    if (mWindowClassName.toString().startsWith("com.forfan.bigbang")){
        //自己的应用不监控
        return;
    }
    if (mCurrentPackage.equals(event.getPackageName())){        
        if (type!=mCurrentType){
            //点击方式不匹配,直接返回
            return;
        }
    }else {
        //包名不匹配,直接返回
        return;
    }
    if (className==null || className.equals("android.widget.EditText")){
        //输入框不监控
        return;
    }
    if (onlyText){
        //onlyText方式下,只获取TextView的内容
        if (className==null || !className.equals("android.widget.TextView")){
            if (!hasShowTipToast){
                ToastUtil.show(R.string.toast_tip_content);
                hasShowTipToast=true;
            }
            return;
        }
    }
    AccessibilityNodeInfo info=event.getSource();
    if(info==null){
        return;
    }
    CharSequence txt=info.getText();
    if (TextUtils.isEmpty(txt) && !onlyText){
        //非onlyText方式下获取文字更多,但是可能并不是想要的文字
        //比如系统短信页面需要这样才能获取到内容。
        List<CharSequence> txts=event.getText();
        if (txts!=null) {
            StringBuilder sb=new StringBuilder();
            for (CharSequence t : txts) {
                sb.append(t);
            }
            txt=sb.toString();
        }
    }
    if (!TextUtils.isEmpty(txt)) {
        if (txt.length()<=2 ){
            //对于太短的词进行屏蔽,因为这些词往往是“发送”等功能按钮,其实应该根据不同的activity进行区分
            if (!hasShowTooShortToast) {
                ToastUtil.show(R.string.too_short_to_split);
                hasShowTooShortToast = true;
            }
            return;
        }
        //打开分词功能
        Intent intent=new Intent(this, BigBangActivity.class);
        intent.addFlags(intent.FLAG_ACTIVITY_NEW_TASK);
        intent.putExtra(BigBangActivity.TO_SPLIT_STR,txt.toString());
        startActivity(intent);
    }
}


private Method getSourceNodeIdMethod;
private long mLastSourceNodeId;
private long mLastClickTime;

private long getSourceNodeId(AccessibilityEvent event)  {
    //用于获取点击的View的id,用于检测双击操作
    if (getSourceNodeIdMethod==null) {
        Class<AccessibilityEvent> eventClass = AccessibilityEvent.class;
        try {
            getSourceNodeIdMethod = eventClass.getMethod("getSourceNodeId");
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        }
    }
    if (getSourceNodeIdMethod!=null) {
        try {
            return (long) getSourceNodeIdMethod.invoke(event);
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
    }
    return -1;
}

private int getClickType(AccessibilityEvent event){
    int type = event.getEventType();
    long time = event.getEventTime();
    long id=getSourceNodeId(event);
    if (type!=TYPE_VIEW_CLICKED){
        mLastClickTime=time;
        mLastSourceNodeId=-1;
        return type;
    }
    if (id==-1){
        mLastClickTime=time;
        mLastSourceNodeId=-1;
        return type;
    }
    if (type==TYPE_VIEW_CLICKED && time - mLastClickTime<= double_click_interval && id==mLastSourceNodeId){
        mLastClickTime=-1;
        mLastSourceNodeId=-1;
        return TYPE_VIEW_DOUBLD_CLICKED;
    }else {
        mLastClickTime=time;
        mLastSourceNodeId=id;
        return type;
    }
}

别看代码挺长,其实挺简单的,这么长的原因是实现了双击的检测(通过getClickType和getSourceNodeId实现的),只是对系统提供的API的一些灵活调用而已,没有什么难的地方。

3.不足之处

通过阅读上面的代码,不难看出辅助模式取词的两个局限:

  1. 点击的View必须是支持辅助服务的,也就是实现了sendAccessibilityEvent()、createAccessibilityNodeInfo()等方法的,而如果是我们自己绘制的View,都是无法使用的(除非非常有节操的程序员开发的)。不过好在大部分情况下,我们都是还是使用系统组件,或者是继承自系统组件。
  2. 只能获取到可点击的View的事件,对于不可点击的View则无能为力。特别是长按事件,必须设置了OnLongClickListener才能触发长按事件。这就导致了,在很多页面(比如系统设置页面)中,如果想监听单击或者双击,就会直接触发单击事件发生页面跳转,而长按则根本无法监听。

由于这两点原因,导致辅助模式取词最适合于QQ、微信、短信等以对话形式出现的文字。因此我们才又添加了各种其他的取词方式作为补充。

源码

完整代码可以参考Bigbang项目的BigBangMonitorService类。

ps:BigBangMonitorService中还包含了全局复制的功能和监听系统按键的功能,阅读的时候不要被干扰了,感兴趣的可以看——使用辅助服务实现全局复制使用辅助服务监听系统按键这两篇文章

我们还基于Xposed框架实现了文字点击触发和全局复制:

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

使用Xposed框架实现全局复制

转载注明出处:十个雨点

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

-- EOF --

Comments