使用无障碍服务完成一键拨打微信视频电话

1,928 阅读13分钟

无障碍服务适配大家应该多多少少的都遇到过,简单点讲就是给图片、文本等控件加上 android:contentDescription=""标签,这样在使用无障碍服务(比如手机自带的 talkback)时,可以将contentDescription的内容以声音的方式读出来,方便视障用户使用我们的 app。

这不是本文的重点,重点是在无障碍-->已安装的服务中中发现了一些其他的应用也提供了一些无障碍服务,比如某输入法提供了"智能回复"、"智能应答"等服务,某些应用还提供了类似于一键进行微信视频通话功能.
这玩意咋搞的?
我们能不能搞?
能不能给老人做一个简单的工具,点个按钮就能和我们进行视频通话?

查了一些资料,我们可以使用AccessibilityService来实现该功能。该服务可以在页面切换或者发生其他变化时回调某些方法,我们可以根据这些回调,获取到页面的节点(控件)信息,来进行点击、长按等操作。
不要用无障碍服务做违法的事情!!!不要用无障碍服务做违法的事情!!!不要用无障碍服务做违法的事情!!!

第一步:创建与配置

我们需要自定义一个继承自AccessibilityService的 service,然后在AndroidManifest.xml文件中注册一下,和普通的service差不多,这里有三个可以被重写方法

import android.accessibilityservice.AccessibilityService;
import android.view.accessibility.AccessibilityEvent;

public class MyAccessibilityService extends AccessibilityService {

    @Override
    public void onAccessibilityEvent(AccessibilityEvent event) {
        // 处理接收到的辅助功能事件
    }

    @Override
    public void onInterrupt() {
        // 处理服务被中断的情况
    }

    @Override
    protected void onServiceConnected() {
        super.onServiceConnected();
        // 初始化服务
    }
}

在清单文件中注册一下

<service
    android:name=".MyAccessibilityService"
    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/accessibility_service_config" />
</service>

需要注意这里的meta-data标签,其中的name属性值是固定的,resource属性则是我们为无障碍服务提供的配置文件。这里取了个名字accessibility_service_config.xml ,其中包含了许多属性,用于定义服务的行为和特性。以下是这些属性的详细介绍和示例说明:

1. android:description

描述服务的用途,通常是一个字符串资源的引用。这个值会展示在开启无障碍服务时的帮助说明中

2. android:accessibilityEventTypes

定义服务要监听的事件类型。可以是以下之一或多个的组合:

  • typeAllMask
  • typeViewClicked
  • typeViewFocused
  • typeViewLongClicked
  • typeViewSelected
  • typeViewTextChanged
  • typeWindowContentChanged
  • typeWindowStateChanged

这里我们只需要监听 typeWindowContentChangedtypeWindowStateChanged 就足够了

3. android:packageNames

指定服务要监听的应用包名。多个包名可以用逗号分隔。这个没啥好说的

4. android:accessibilityFeedbackType

定义服务的反馈类型,就是如何给用户反馈,可以是以下之一或多个的组合:

  • feedbackSpoken : 适用于需要将信息通过语音读出来的情况,例如屏幕阅读器。
  • feedbackHaptic : 适用于需要通过振动提醒用户的情况,例如通知用户某个操作成功或失败。
  • feedbackAudible : 适用于需要通过音效提醒用户的情况,例如提示音。
  • feedbackVisual : 适用于需要通过视觉效果(如闪烁、颜色变化)提醒用户的情况。
  • feedbackGeneric : 适用于不特定于某一种反馈类型的情况。
  • feedbackBraille : 适用于需要将信息传递给盲文设备用户的情况。

5. android:notificationTimeout

定义服务在处理连续事件之间的最短时间间隔,以毫秒为单位。当辅助功能服务接收到大量的连续事件时,可能会导致性能问题或用户体验不佳。通过设置 notificationTimeout,可以指定一个时间窗口,在这个时间窗口内重复的事件将被合并为一个事件,从而减少处理的频率。

6. android:canRetrieveWindowContent

定义服务是否可以检索窗口内容。设置为 true 表示服务可以访问窗口内容。

7. android:settingsActivity

指定一个设置活动的类名,用户可以通过辅助功能设置页面进入该活动。配置了该属性之后,用户可以在开启无障碍服务页面点击更多设置直接进入到该页面

8. android:canRequestTouchExplorationMode

属性用于指定辅助功能服务是否可以请求触摸探索模式。触摸探索模式是一种特殊的输入模式,通常用于帮助视力障碍用户使用触摸屏设备 这里也需要我们在代码中设置一下

override fun onServiceConnected() {
    super.onServiceConnected()
    val info = AccessibilityServiceInfo()
    info.flags = AccessibilityServiceInfo.FLAG_REQUEST_TOUCH_EXPLORATION_MODE
    serviceInfo = info
}

并且,在处理事件中我们也需要处理更多的事件

启用时:应用需要处理更多的辅助功能事件,如 TYPE_TOUCH_EXPLORATION_GESTURE_START 和 TYPE_TOUCH_EXPLORATION_GESTURE_END。这些事件帮助应用确定用户正在进行触摸探索。 未启用时:应用只需处理标准的触摸事件。

9. android:canRequestEnhancedWebAccessibility

定义服务是否可以请求增强的网页辅助功能。

10. android:canRequestFilterKeyEvents

用于指定辅助功能服务是否可以请求过滤键事件(key events)。这对于开发辅助功能服务(如屏幕阅读器或其他辅助工具)非常重要,因为它允许这些服务拦截和处理按键事件,以提供更好的用户体验和辅助功能支持。同样的,不仅要在配置文件中声明,也需要在代码中设置

override fun onServiceConnected() {
    super.onServiceConnected()
    val info = AccessibilityServiceInfo()
    info.flags = AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS
    serviceInfo = info
}

11. android:canPerformGestures

定义服务是否可以执行手势。如果为 true,我们可以这样执行手势

// 执行点击手势
private void performClick(float x, float y) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        Path clickPath = new Path();
        clickPath.moveTo(x, y);
        GestureDescription.StrokeDescription clickStroke = new GestureDescription.StrokeDescription(clickPath, 0, 100);
        GestureDescription gestureDescription = new GestureDescription.Builder().addStroke(clickStroke).build();
        dispatchGesture(gestureDescription, null, null);
    }
}

12. android:accessibilityFlags

定义服务的辅助功能标志,这些标志定义了服务的行为和特性。通过设置不同的标志,开发者可以控制辅助功能服务如何与系统和应用交互。可以是以下之一或多个的组合:

  1. flagIncludeNotImportantViews

    • 作用:包括那些通常被认为不重要的视图(如布局视图)在辅助功能事件中。
    • 使用场景:当需要确保所有视图都被辅助功能服务处理时使用。
  2. flagRequestTouchExplorationMode

    • 作用:请求触摸探索模式,这对于视力障碍用户非常有用。
    • 使用场景:当辅助功能服务需要解释触摸事件并提供反馈时使用。
  3. flagReportViewIds

    • 作用:报告视图的资源 ID。
    • 使用场景:当辅助功能服务需要识别和操作特定视图时使用。
  4. flagRetrieveInteractiveWindows

    • 作用:允许辅助功能服务检索交互窗口。
    • 使用场景:当需要处理多个窗口或弹出窗口时使用。

当然我们也可以在代码中设置标志

在你的 AccessibilityService 中,你可以使用 AccessibilityServiceInfo 来设置标志:

    override fun onServiceConnected() {
        super.onServiceConnected()
        val info = AccessibilityServiceInfo()
        info.flags = (AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS
                or AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS
                or AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS)
        serviceInfo = info
    }

示例完整配置文件

下面是我们这次需要用到的配置文件内容:

<?xml version="1.0" encoding="utf-8"?>
<accessibility-service
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:accessibilityEventTypes="typeWindowContentChanged|typeWindowStateChanged"
    android:accessibilityFeedbackType="feedbackGeneric"
    android:accessibilityFlags="flagIncludeNotImportantViews|flagReportViewIds"
    android:canRetrieveWindowContent="true"
    android:canRequestTouchExplorationMode="true"
    android:canRequestFilterKeyEvents="true"
    android:canPerformGestures="true"
    android:packageNames="com.tencent.mm"
    android:settingsActivity="com.huangyuanlove.auxiliary.SettingActivity"
    android:description="@string/wx_make_call_service_helper"
    android:notificationTimeout="100"/>

通过配置这些属性,你可以精确地控制 AccessibilityService 的行为,以满足特定的需求和用例。

accessibility_setting_honor.png accessibility_setting_k30.png

左边图片为荣耀v10上开启应用无障碍服务的截图。右边图片为红米k70pro开启应用无障碍服务的截图.

第二步:rua代码

在上面我们已经做好了基础配置,下面开始rua 代码,看看我们应该怎么做。

分析路径流程

我们先做好微信的前期准备工作:
通话双方是好友、微信已经登录。
那么我们的使用流程大致是这样的:
打开微信
点击底部通讯录
找到这个好友(可能需要滑动通讯录列表)点击一下进入到好友信息页面
点击信息页面的音视频通话
在底部弹窗中点击视频通话或者语音通话

简单的 API 调用准备

onAccessibilityEvent

当触发了我们在配置文件中指定的事件时,系统会回调AccessibilityService#onAccessibilityEvent(event: AccessibilityEvent)这个方法。 我们可以通过event对象获取触发这个事件的包名,触发的事件类型等,

getRootInActiveWindow

我们可以在AccessibilityService中调用这个方法获取当前页面的根节点,这个节点可以看做是当前视图树的根节点,这样我们就可以遍历整个视图树了。 同样的,我们也可以通过AccessibilityNodeInfo实例来获取对应节点的属性,比如是否可以点击(isClickable)、类型(className)、按钮|文本内容(text)、无障碍服务标签内容(contentDescription)等。我们可以根据这些属性来判断是不是我们需要的节点(控件)

注意事项

就像我们平时开发一样,有些事件并不是直接设置在 TextView 或者 Button 上的,可能是设置在它们的父级组件上,比如LinearLayout或者RelativeLayout等。所以当我们获取到对应的节点后,需要判断一下是不是我们需要的节点,如果不是的话,就在找找父级是不是我们需要的节点。 当然如果我们知道某个页面某个节点的id,就不需要这么麻烦了,直接根据 id 查找就好了。

权限

需要开启无障碍服务才可以进行对应的操作

fun isServiceEnabled(context: Context): Boolean {
    (context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager)
            .getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
            .filter { it.id == "${context.packageName}/${MakeWeChatCallService::class.java.name}" }
            .let { return it.isNotEmpty() }
}

//跳转到开启无障碍服务页面
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) 

动手开工

打开微信

这个很简单哇,知道微信的包名就好了

val intent = packageManager.getLaunchIntentForPackage(WX_PACKAGE_NAME)
intent?.let {
    startActivity(intent)
}

当我们打开微信后,标记接下来需要点击通讯录按钮:current_step= click_contacts;

点击底部通讯录

这个就需要用到上面准备好的AccessibilityService了,按照上面的配置,当我们打开微信之后,就开始回调onAccessibilityEvent(event: AccessibilityEvent)这个方法了。

我们假设用户使用的是中文,我们需要找到"通讯录"这个按钮对应的AccessibilityNodeInfo实例,然后调用performAction(AccessibilityNodeInfo.ACTION_CLICK)进行点击就好了。 注意,这个的通讯录文本并不是可以点击的,我们打开无障碍服务talkback将框框移动到通讯录这里,就可以看到通讯录和上面的图标是一体的。但我们也不清楚他们到底是怎么实现的,所以我们查找这个文本的父级控件,看是否能点,不能点击就再往上查找。多次尝试之后,发现需要向上查找两次。这里写了一个扩展方法

private fun AccessibilityNodeInfo.clickNodeByText(
    textList: Array<String>,
    parentCount: Int = 0
): Boolean {
    var node = getNodeByText(textList)
    repeat(parentCount) {
        node = node?.parent
    }
    node?.let {
        return it.performAction(AccessibilityNodeInfo.ACTION_CLICK)
    }
    return false
}

这里的参数parentCount表示需要向上查找几次。 我们点击通讯录的时候调用rootInActiveWindow.clickNodeByText(arrayOf("通讯录"), 2)就可以了. 点击成功后,我们标记接下来需要点击联系人:current_step=click_contact;

找到好友

通讯录是个列表,我们猜要不是 ListView,要不是 RecyclerView,我觉得不大可能是 ScrollView。要注意。右侧还有一个字母列表,不要搞错了。 我们先从当前可看到的页面查找联系人。 这里也搞了个扩展方法

private fun AccessibilityNodeInfo.getNodeByText(textList: Array<String>): AccessibilityNodeInfo? {
    var node: AccessibilityNodeInfo? = null
    var index = 0
    while (index < textList.size && node === null) {
        node = this.findAccessibilityNodeInfosByText(textList[index]).getOrNull(0)
        index++
    }
    return node
}

查找这个联系人

var contactNode = rootInActiveWindow.getNodeByText(arrayOf(cantactName))

如果contactNode为空,表示当前可视内容中没有这个联系人,我们需要滑动列表。 首先,找到联系人列表的RecyclerView,别问为啥是RecyclerView,试了好多次试出来的。

private fun getContactListView(): AccessibilityNodeInfo? {
    val queue = LinkedList<AccessibilityNodeInfo>()
    queue.offer(rootInActiveWindow)
    var info: AccessibilityNodeInfo?
    while (!queue.isEmpty()) {
        info = queue.poll()
        if (info == null) {
            continue
        }
        if (info.className.equals("androidx.recyclerview.widget.RecyclerView") && info.isScrollable) {
            return info
        }
        for (i in 0 until info.childCount) {
            queue.offer(info.getChild(i))
        }
    }
    return null
}

找到列表控件后滑动一下

val contactListNode = getContactListView()
contactListNode?.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)

注意,这里列表的滑动同样会触发onAccessibilityEvent这个方法,我们再重复上面的流程,直到找到这个联系人控件。需要注意的是,这里的联系人显示的名字要是单个英文字母,这会和列表分组上面的单个英文字母相同,导致查找到的控件不是我们想要的

当我们找到这个联系人控件后,进行点击

var contactNode = rootInActiveWindow.getNodeByText(arrayOf(cantactName))
repeat(6) {//别问这里为啥是 6,试出来的,或者可以遍历一下视图树,自己数一下层级
    contactNode = contactNode?.parent
}
contactNode?.let {
    val result = it.performAction(AccessibilityNodeInfo.ACTION_CLICK)
    if(result){
        //标记接下来需要在联系人详情页面点击音视频通话
        current_step= click_video;
    }
}

点击音视频通话

这个就比较简单了,还是调用我们上面写的扩展方法找到按钮,然后点击就行了

var contactNode = rootInActiveWindow.getNodeByText(arrayOf("音视频通话"))
repeat(2) {
    contactNode = contactNode?.parent
}
contactNode?.let {
    val result = it.performAction(AccessibilityNodeInfo.ACTION_CLICK)
    if(result){
        //标记接下来需要点击弹窗中的视频通话
        current_step= click_video_on_dialog;
    }
}

点击弹窗中的视频通话

这个就更简单了

rootInActiveWindow.clickNodeByText(arrayOf("语音通话"), 3)
rootInActiveWindow.clickNodeByText(arrayOf("视频通话"), 3)

一个语音通话,一个视频通话。 到这里我们就可以进行视频通话了。

注意事项

  1. 使用一个变量来标记当前需要做哪一步。
  2. onAccessibilityEvent回调比较频繁,有些老旧手机卡顿比较严重,发生回调时可能页面还没有展示出来,因此可以在回调后延迟一段时间时间执行。
    // 当窗口发生的事件是我们配置监听的事件时,会回调此方法.会被调用多次
    override fun onAccessibilityEvent(event: AccessibilityEvent) {
        if (event.packageName == WX_PACKAGE_NAME && (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED || event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED)) {
MMKV.mmkvWithID(INVOKE_DAILY_TIME_MMKV_NAME).getInt(INVOKE_DAILY_TIME_MULTIPLE,1)
            when (WeChatCallStepManager.step) {
                Step_Start -> {
                    handler.removeMessages(Step_Start)
                    handler.sendEmptyMessageDelayed(Step_Start, 100 * timeMultiple.toLong())
                }

                Step_search_contact -> {
                    handler.removeMessages(Step_search_contact)
                    handler.sendEmptyMessageDelayed(Step_search_contact, 100 * timeMultiple.toLong())
                }

                Step_search_and_click_video -> {
                    handler.removeMessages(Step_search_and_click_video)
                    handler.sendEmptyMessageDelayed(Step_search_and_click_video, 100* timeMultiple.toLong())
                }

                Step_start_video_chat -> {
                    handler.removeMessages(Step_start_video_chat)
                    handler.sendEmptyMessageDelayed(Step_start_video_chat, 500* timeMultiple.toLong())
                }

                else -> {
                    Log.e("huangyuan", "当前步骤未进行 $WeChatCallStepManager.step")
                }
            }
        }
    }

总结

我们可以使用无障碍服务做一些有意思的事情,来帮助我们更好的使用各种软件。比如对于家里文化程度不高、不能熟练的使用智能手机的老人,打个电话、保存电话号码对他们来讲都是很困难的事情,更别提一些更复杂的软件了。
我们就可以将常用的软件功能集中在一个软件中,甚至可以做一个老人桌面,点击桌面上的某个按钮就能实现一键拨打微信视频、保存电话号码到通讯录、一键拨打电话等功能。

其他问题

上面的流程是最理想的状态,还有一些意料之外的问题:
比如我们视频通话结束后,需要返回到列表页,也就是在通话结束后点击左上角的返回,这个功能没有写。
比如打开微信的时候不是在首页,比如在浏览公众号信息怎么办?同样需要找到左上角的返回按钮,一直到首页之后才可以进行点击通讯录的操作。
比如联系人的名字就是单个英文字母,上面也提到,这种情况下查找到的会是分组的名称,无法进行点击。
另外一条路径: 或者我们可以从首页点击右上角的搜索,输入联系人名字,然后在搜索列表中点击联系人,进入到聊天页面,然后点击左下角加号,在更多菜单里面点击音视频通话也行。

仓库链接:github.com/huangyuanlo…