吐槽
最近几个月,家里增加了两位新成员,NIDA和Water,NIDA是一只中华田园猫,是在7月份"妮坦"台风登陆前一晚和女票结缘并收养的,NIDA个性比较凶,喜欢把人的腿当猎物来偷袭和抱腿咬,Water是比NIDA晚来的小金毛,来的时候三个月,好动,喜欢用口去"咬"猫子,把NIDA的玩具占为己有,刚开始还经常偷吃猫粮、水....NIDA也是无可奈何、通常被搞到满是狗子的口水,作为猫星人的尊严呢?但NIDA逃起来,上蹿下跳,Water也只能望尘莫及,可以想象的是,每天下班回来迎接而来的是,几乎被洗劫过的家,还有Water的💩和尿尿(是的,傻狗还没学会在厕所方便呢o(≧口≦)o,汪的一声就哭了),每天上班放这两只东西在家还是有点担忧的,买一个视频监控器,少说也需要个一两百(可以帮它俩买不少零食了),加上自己手上就有一台闲置的碎屏手机(换个屏幕也要100多啊),所以就想要不自己开发一个远程视频监控系统,在需要的时候可以监控一下家里的情况
想法
为了实现视频通讯,使用手机QQ提供的视频电话功能就可以了,足够稳定,所以荒废多年的备用QQ终于可以用上场(不要羡慕我这个有两个QQ的男人),通讯对象分为Client端和Server端,至于通讯模型则是Client端发送特定的命令到Server端,Server端解析Client端的命令,像Client端发起QQ视频聊天,Client端只需要等待并接收视频聊天,最后就可以监控到Server端摄像头的影像,看起来还是SO EASY的,那就开干吧
实现
主要的问题是如何在非人工干预的情况下实现自动化操作,系统的辅助服务功能可以很好的解决这个问题,相信大部分开发者都知道可以用辅助服务来编写微信抢红包插件,具体参见该项目,AccessibilityService的使用也算简单,无非就是监听某种或多种类型事件(通知中心、窗口内容、窗口状态、焦点改变等)的改变,关于AccessibilityService的配置和使用可以看看你真的理解AccessibilityService吗或者直接看官方文档吧,就不在这里唠叨了
状态转换
确定了使用AccessibilityService实现自动化操作的功能后,先来整理一下整个功能的流程或者说场景的转换,见下图:
可以看出,场景还是不少的,每个场景都需要我们去完成特定的操作,例如在锁屏监听到QQ消息的到来,我们需要检测是不是来自我们的Client(在项目里我以【WaterMonitor:QQ号】为标志,通过在Server修改Client的QQ备注处理,这样的格式也方便获取到需要进行视频电话的QQ联系人),且请求的命令,这些都符合的话,模拟HOME键进入锁屏界面,在锁屏界面还需要模拟上划操作进入解锁界面,并在解锁界面输入正确密码进行解锁,对于这种在不同的场景(状态)的转换并作出相应处理的情景下,我可不想通过If/else来判断当前的状态,并处理,这样大大的增加了程序的耦合性,并且考虑到以后可能在打开QQ的时候,提示登录过期,那我就需要增加一个自动登录的检测和操作,为了解耦,这里使用状态机模式正好,下图是该程序的状态图:
下面简单介绍下各个状态的责任
状态 | 责任 |
---|---|
IdleState | 检测Client的命令,并解锁屏幕打开QQ聊天界面 |
QQChatState | 检测是否聊天界面,查找➕号键,点击调出更多功能面板 |
StartVideoState | 检测到视频电话按钮并点击,发起视频通话 |
EndingSate | 通话结束,检测到列表最后一个Item是否是通话结束\取消\拒绝,熄屏重置状态为IdleState |
这里就挑IdleState
来简单解析下
辅助服务的配置
public class VideoAccessibilityService extends AccessibilityService implements IMonitorService {
private MonitorState mCurState;
@Override
protected void onServiceConnected() {
super.onServiceConnected();
registerScreenReceiver();
AccessibilityServiceInfo info = new AccessibilityServiceInfo();
info.eventTypes = TYPE_WINDOW_CONTENT_CHANGED | TYPE_WINDOWS_CHANGED | TYPE_WINDOW_STATE_CHANGED | TYPE_NOTIFICATION_STATE_CHANGED;
info.packageNames = new String[]{Constant.QQ_PKG};
//...
this.setServiceInfo(info);
setState(new IdleState(this));
}
@Override
public void onAccessibilityEvent(AccessibilityEvent accessibilityEvent) {
if (mCurState != null) {
mCurState.handle(accessibilityEvent);
}
}
//...
}
mCurState
记录了当前的状态,并在onAccessibilityEvent
方法回调的时候交由当前状态去处理事件,onAccessibilityEvent
监听的事件在onServiceConnected
方法中配置,监听的事件类型TYPE_WINDOW_CONTENT_CHANGED | TYPE_WINDOWS_CHANGED | TYPE_WINDOW_STATE_CHANGED | TYPE_NOTIFICATION_STATE_CHANGED
,分别对应了窗口的内容(例如增加某个View),窗口的显示(显示在前台的时候),窗口的状态(Dialog弹出导致窗口失去焦点等)和通知栏状态改变事件类型,监听的包名是QQ的包名,其中通知栏的改变不受包名影响
IdleState的处理
/**
* 初始状态,等待来电处理
* change to monitor QQ new message (LockScreen, Notification , QQ App)
* Created by chensuilun on 16-10-9.
*/
public class IdleState extends MonitorState {
//...
@Override
public void handle(AccessibilityEvent accessibilityEvent) {
AccessibilityNodeInfo nodeInfo = mContextService.getWindowNode();
if (nodeInfo == null) {
return;
}
if (isLockScreenMonitorMsg(nodeInfo, accessibilityEvent) || isNotificationMonitorMsg(nodeInfo, accessibilityEvent)) {
if (AppUtils.isInLockScreen()) {
// back press
RootCmd.execRootCmd("input keyevent " + KeyEvent.KEYCODE_BACK);
// press HOME
RootCmd.execRootCmd("sleep 0.1 && input keyevent " + KeyEvent.KEYCODE_HOME);
unlockScreen(nodeInfo);
}
final String qqNumber = retrieveQQNumber(nodeInfo, accessibilityEvent);
mContextService.setState(new QQChatState(mContextService));
AppApplication.postDelay(new Runnable() {
@Override
public void run() {
AppUtils.openQQChat(qqNumber);
}
}, 1000);
}
}
/**
* retract monitor cmd from notification
*
* @param nodeInfo
* @param accessibilityEvent
* @return
*/
private boolean isNotificationMonitorMsg(AccessibilityNodeInfo nodeInfo, AccessibilityEvent accessibilityEvent) {
if (accessibilityEvent.getEventType() == TYPE_NOTIFICATION_STATE_CHANGED) {
Parcelable data = accessibilityEvent.getParcelableData();
if (data instanceof Notification) {
if (((Notification) data).tickerText != null) {
return (((Notification) data).tickerText.toString().startsWith(MONITOR_TAG)
&& ((Notification) data).tickerText.toString().endsWith(Constant.MONITOR_CMD_VIDEO));
}
}
}
return false;
}
/**
* @param nodeInfo
* @param accessibilityEvent
* @return If from notification ,msg format :{@link Constant#MONITOR_TAG} + ":real QQ No: "+{@link Constant#MONITOR_CMD_VIDEO}
*/
private String retrieveQQNumber(AccessibilityNodeInfo nodeInfo, AccessibilityEvent accessibilityEvent) {
if (accessibilityEvent.getEventType() == TYPE_NOTIFICATION_STATE_CHANGED) {
Parcelable data = accessibilityEvent.getParcelableData();
if (data instanceof Notification) {
if (((Notification) data).tickerText != null) {
return ((Notification) data).tickerText.toString().split(":")[1];
}
}
} else {
List<AccessibilityNodeInfo> nodeInfos = nodeInfo.findAccessibilityNodeInfosByText(MONITOR_TAG);
if (!AppUtils.isListEmpty(nodeInfos)) {
String tag;
for (AccessibilityNodeInfo info : nodeInfos) {
tag = (String) info.getText();
if (!TextUtils.isEmpty(tag) && tag.contains(MONITOR_TAG)) {
return tag.substring(Constant.MONITOR_TAG.length());
}
}
}
}
return Privacy.QQ_NUMBER;
}
/**
* receive monitor cmd in LockScreen
*
* @param nodeInfo
* @param accessibilityEvent
* @return
*/
private boolean isLockScreenMonitorMsg(AccessibilityNodeInfo nodeInfo, AccessibilityEvent accessibilityEvent) {
if (AppUtils.isInLockScreen() && Constant.QQ_PKG.equals(nodeInfo.getPackageName()) && TYPE_WINDOW_CONTENT_CHANGED == accessibilityEvent.getEventType()) {
if (!AppUtils.isListEmpty(nodeInfo.findAccessibilityNodeInfosByText(MONITOR_TAG))
&& !AppUtils.isListEmpty(nodeInfo.findAccessibilityNodeInfosByText(Constant.MONITOR_CMD_VIDEO))) {
return true;
}
}
return false;
}
/**
* 解锁
* @param nodeInfo
*/
private void unlockScreen(AccessibilityNodeInfo nodeInfo) {
UnLockUtils.unlock();
}
}
IdelState
处理的是QQ包名的窗口的改变或者通知栏的改变,isLockScreenMonitorMsg
在锁屏收到了QQ包名相关的窗口内容改变的事件,通过查找WaterMonitor
标志和命令1
来决定是否收到了Client端的命令,具体窗口的内容看场景转换图1,isNotificationMonitorMsg
则是检测通知栏改变的内容来判断,如果接受到备注为WaterMonitor:111
的Client发来的命令1
,通过读取通知栏的内容得到的是WaterMonitor:111 1
,如果是Client端的视频命令,那么接着判断是否在锁屏,然后解锁,否则就直接查找到联系人的QQ,打开QQ聊天界面并修改状态为QQChatState
,接下来的事情就交给QQChatState
来处理,这里并没有监听来自QQ主程序的消息列表和聊天面板的新消息,主要是因为比较难判断新来的命令是否已经处理过,但并不影响程序的使用,因为在屏幕熄灭或者聊天结束(EndingSate
)的时候都进行了状态的初始化并熄灭屏幕
其他的状态的套路也一样
Root和屏幕解锁
在开发的过程发现,单纯的使用服务服务还是不够的,就是无法进行屏幕解锁,解锁界面大部分都是自定义View实现的,且一般也不支持辅助功能,这是开发中遇到最大的难题,甚至想过如果搞不定锁屏就放弃算了,虽然可以通过禁用安全锁屏来轻松避开这个问题,但对于我来说,是不太能接收这样的限制的,最后为了实现解锁,最后发现通过adb input
命令就可以模拟用户按键、触摸等操作,详细的使用可以看这里,我这里稍微解析下我的解锁脚本
sleep 0.1 && input keyevent 3
input swipe 655 1774 655 874
sleep 1 && input tap 612 726
sleep 0.1 && input tap 813 1000
sleep 0.1 && input tap 813 1000
sleep 0.1 && input tap 255 1000
quit
keyevent
等于3,代表这是HOME键事件,所以第一行的作用等同于点击HOME键,更多的KEYCODE可以查看android.view.KeyEvent
这个类,swipe
是滑动操作,即模拟手指从(655,1774)滑动到(655,874),也就是手指上划,主要是进入到解锁界面,tap
是点击操作,后面跟的是点击的坐标,所以接下来的四次tap,是模拟点击解锁界面的某些数字,quit
是程序本身用于判断脚本结束的标志,并不是adb命令。为了能适配不同的手机,所以把解锁脚本独立出来,放到SD卡根目录,文件名为MonitorUnlock.txt
,再根据自己的手机解锁操作,编写好对应的解锁脚本即可,需要解锁的时候就从SD卡中读取该文件
关于是如何确定坐标的,其实很简单,打开开发者模式-指针位置即可查看自己实际操作时候的坐标值
另外为了能够执行adb命令,所以需要Root权限
最后
为了保证程序和QQ能够后台运行,所以记得添加到系统清理的白名单哦,还有如果使用的是国产ROM,最好把程序添加到系统的开启启动项,可以不需要每次重启都手动开启辅助服务
项目已经上传到Github,欢迎Start💕