基于 Android Accessibility 实现一键拨打微信电话

本文对小项目 QuickWeChatCall 进行了简要的回顾。

1. 概述

1.1. 动机

奶奶非常喜欢和我跟姐姐视频聊天,但是微信的视频步骤实在太过复杂,对于打电话都有困难的奶奶来说几乎不可能独立使用的。为此我编写了 QuickWeChatCall 这个工具,用于帮助需要的人一键发起微信视频。

ビデオチャットしましょう、おばあちゃん。:-)

项目地址:QuickWeChatCall

下载:Release

使用教程:Usage

1.2. 结果

运行截图:

img

实现了以下功能:

  • 一键发起视频聊天
  • 自动接听视频 / 语音聊天
  • 快捷联系人列表

1.3. 启发

在自己开发之前,先测试了两个网络上提供的解决方案:

  1. Auto.js,支持使用 JavaScript 操控 Android 的无障碍服务。优点是开发迅速,只需要几行 JavaScript 代码就可以了,缺点则是太过硬核,不适合老年人使用。
  2. Mozzie一个知乎回答 中提供的基于 Android 无障碍服务的一键视频应用。但在试用之后发现作者基本只是写了个 Demo,几乎没有易用性,而且核心功能——一键发起视频,已经无法使用了。

最后我决定同样基于 Android 无障碍服务,开发一个简单易用的一键发起视频聊天工具。也就诞生了 QuickWeChatCall 项目。

1.4. 原理

原理非常简单,就是利用 Android Accessibility Service,监听微信的 UI 变动。根据一定的步骤,寻找指定的 UI 组件,执行点击操作即可。

2. 细节

2.1. 无障碍权限

声明

无障碍服务的权限声明需要在 AndroidManifest.xml 中声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<application>
<service
android:name="无障碍服务类名"
android:label="无障碍服务名称"
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_config" />
</service>
</application>

然后在 xml/accessibility_config 中声明:

1
2
3
4
5
6
7
8
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackSpoken"
android:accessibilityFlags="flagIncludeNotImportantViews"
android:canRetrieveWindowContent="true"
android:description="无障碍服务描述"
android:notificationTimeout="100"
android:packageNames="监听包名" />

这里只解释几个关键的字段

  • 无障碍服务类名:提供无障碍服务的服务类的名称,后续细谈
  • 无障碍服务名称:在系统设置中显示的无障碍服务的名称
  • 无障碍服务描述:在系统设置中显示的无障碍服务的描述
  • 监听包名:无障碍服务监听的包名,比如这里我们需要监听微信的 UI 更新,因此包名就是微信的包名 com.tencent.mm

检查

无障碍服务的授权比较特殊,不像其他权限一样可以通过 Dialog 的方式提示用户授权,而是需要在系统设置的无障碍服务中手动开启。

因此最好的办法就是在启动的时候检查无障碍权限情况,如果没有得到授权就转跳到系统设置界面。

检查无障碍权限的代码,参考了 Stack Overflow 上的答案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private boolean isAccessibilitySettingsOn(Context mContext) {
int accessibilityEnabled = 0;
final String service = getPackageName() + "/" + AccessibilityService.class.getCanonicalName();
try {
accessibilityEnabled = Settings.Secure.getInt(
mContext.getApplicationContext().getContentResolver(),
android.provider.Settings.Secure.ACCESSIBILITY_ENABLED);
} catch (Settings.SettingNotFoundException e) {
Log.e(TAG, "Error finding setting, default accessibility to not found: " + e.getMessage());
}
TextUtils.SimpleStringSplitter mStringColonSplitter = new TextUtils.SimpleStringSplitter(':');

if (accessibilityEnabled == 1) {
String settingValue = Settings.Secure.getString(
mContext.getApplicationContext().getContentResolver(),
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES);
if (settingValue != null) {
mStringColonSplitter.setString(settingValue);
while (mStringColonSplitter.hasNext()) {
String accessibilityService = mStringColonSplitter.next();
if (accessibilityService.equalsIgnoreCase(service)) {
Log.v(TAG, "***ACCESSIBILITY IS ENABLED***");
return true;
}
}
}
}
Log.v(TAG, "***ACCESSIBILITY IS DISABLED***");
return false;
}

跳转到系统无障碍服务设置界面:

1
startActivity(new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS));

2.2. 无障碍服务

当应用开启无障碍服务权限之后,注册的无障碍服务类就会在后台运行。当注册包名的 UI 发生变化是,就会触发事件,并调用无障碍服务类中相应的回调。这些事件可能是,内容变更、创建或删除节点等。

所以核心逻辑就是在无障碍服务类中,处理对应的事件。

2.3. 逻辑

整个发起微信视频的步骤经过多次调整最后确定为:

  1. 打开微信
  2. 点击微信下方导航条中的“联系人”
  3. 点击联系人界面的“标签“
  4. 点击标签列表中的”微信一键视频“标签
  5. 点击对应的微信好友
  6. 点击”视频聊天“按钮
  7. 选择”视频聊天“

因此,程序运行的逻辑就是

  1. 打开微信,将当前步骤设定为上述步骤 2
  2. 等待 UI 更新
  3. UI 更新后,在界面中寻找该步骤需要点击的元素,点击
  4. 将步骤设置为下一步
  5. 回到 2,重复执行,直到结束

2.4. 去抖动

因为 UI 更新造成的事件可能非常密集,例如家在列表的时候,有可能每一个列表项的加入都会造成一次事件回调。而程序其实只需要在一次 UI 更新的结束阶段,寻找指定元素就可以了。所以需要一定的去抖动机制。

原理其实也非常简单,可以参照 Lodash 中的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
if (finished) {
finished = false;
} else {
Log.v(TAG, "bounce");
handler.removeCallbacks(new Runnable() {
@Override
public void run() {
_onAccessibilityEvent(input);
finished = true;
}
});
}
input = event;
handler = new Handler();
handler.postDelayed(runnable, WAIT);
}

3. 坑

3.1. 无障碍服务授权期限

获得无障碍服务的权限之后,无障碍服务类就在后台运行了。但如果这时候由于任何情况(关机、重启、后台被杀、自身异常等等),无障碍服务类关闭了,那么无障碍服务的权限就会自动丢失,必须重新授权。

3.2. 微信首页组件搜索

无障碍服务提供了一个函数 findAccessibilityNodeInfosByText 用来搜索当前布局中的元素,但是不知为何,在微信首页调用这个函数搜索不到任何内容。然而自己手动遍历的话,却可以搜索到。

因此我手动实现了一个深度优先搜索的多属性搜索函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private AccessibilityNodeInfo findNode(AccessibilityNodeInfo root, Property type, String text) {
if (root == null) return null;
boolean satisfied = false;
switch (type) {
case TEXT:
satisfied = root.getText() != null && text.contentEquals(root.getText());
break;
case CLASS_NAME:
satisfied = root.getClassName() != null && text.contentEquals(root.getClassName());
break;
case DESCRIPTION:
satisfied = root.getContentDescription() != null && text.contentEquals(root.getContentDescription());
break;
}
if (satisfied) {
return root;
} else {
for (int i = 0; i < root.getChildCount(); i++) {
AccessibilityNodeInfo result = findNode(root.getChild(i), type, text);
if (result != null) {
return result;
}
}
}
root.recycle();
return null;
}

3.3. 魅族调试

在模拟器上装微信遇到一些问题,因此我就直接在手头唯一的魅蓝 5 上实机调试。但是发现在魅蓝 5 上无论如何都获取不到 Log 信息,后来发现需要在 系统设置 -> 开发者选项 -> 性能优化 -> 高级日志输出 中选择 全部允许

4. 后来

后来由于,上面也提到的无障碍权限总是需要重复授权的问题,虽然简单开发完了这个工具,最后还是放弃了使用。毕竟对于奶奶来说,如果一不小心开关机或者清理了后台,重新授权确实太复杂了。

最后的解决方法是,FaceTime 😂

没错,Apple 大法好。通过捷径配合 FaceTime,奶奶可以在 iPad 上一键发起视频聊天。

唯一的缺点大概就是必须是对方也必须是苹果设备,恰好我和姐姐都是✌️。

感谢洛伊酱 ❤️ 提供的 iPad 呀,奶奶超开心。