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

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

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

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

第一步:创建与配置

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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();
// 初始化服务
}
}

在清单文件中注册一下

1
2
3
4
5
6
7
8
9
10
11
<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 是一个用于配置 AccessibilityService 的 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

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

1
2
3
4
5
6
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)。这对于开发辅助功能服务(如屏幕阅读器或其他辅助工具)非常重要,因为它允许这些服务拦截和处理按键事件,以提供更好的用户体验和辅助功能支持。同样的,不仅要在配置文件中声明,也需要在代码中设置

1
2
3
4
5
6
override fun onServiceConnected() {
super.onServiceConnected()
val info = AccessibilityServiceInfo()
info.flags = AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS
serviceInfo = info
}

11. android:canPerformGestures

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

1
2
3
4
5
6
7
8
9
10
// 执行点击手势
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 来设置标志:

1
2
3
4
5
6
7
8
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
}

示例完整配置文件

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?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 的行为,以满足特定的需求和用例。

荣耀v10开启无障碍服务弹窗提示
红米k30p开启无障碍服务弹窗提示

第二步:rua代码

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

分析路径流程

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

简单的 API 调用准备

onAccessibilityEvent

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

getRootInActiveWindow

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

注意事项

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

权限

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

1
2
3
4
5
6
7
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() }
}

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

动手开工

打开微信

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

1
2
3
4
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将框框移动到通讯录这里,就可以看到通讯录和上面的图标是一体的。但我们也不清楚他们到底是怎么实现的,所以我们查找这个文本的父级控件,看是否能点,不能点击就再往上查找。多次尝试之后,发现需要向上查找两次。这里写了一个扩展方法

1
2
3
4
5
6
7
8
9
10
11
12
13
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。要注意。右侧还有一个字母列表,不要搞错了。
我们先从当前可看到的页面查找联系人。
这里也搞了个扩展方法

1
2
3
4
5
6
7
8
9
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
}

查找这个联系人

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
}

找到列表控件后滑动一下

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

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

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

1
2
3
4
5
6
7
8
9
10
11
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;
}
}

点击音视频通话

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

1
2
3
4
5
6
7
8
9
10
11
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;
}
}

点击弹窗中的视频通话

这个就更简单了

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

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

总结

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


使用无障碍服务完成一键拨打微信视频电话
https://blog.huangyuanlove.com/2024/08/16/使用无障碍服务做一个一键拨打微信视频电话的应用/
作者
HuangYuan_xuan
发布于
2024年8月16日
许可协议
BY HUANG兄