使用辅助服务打造自己的智能视频监控系统

1,218 阅读8分钟

最近几个月,家里增加了两位新成员,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实现自动化操作的功能后,先来整理一下整个功能的流程或者说场景的转换,见下图:


场景转换.png

可以看出,场景还是不少的,每个场景都需要我们去完成特定的操作,例如在锁屏监听到QQ消息的到来,我们需要检测是不是来自我们的Client(在项目里我以【WaterMonitor:QQ号】为标志,通过在Server修改Client的QQ备注处理,这样的格式也方便获取到需要进行视频电话的QQ联系人),且请求的命令,这些都符合的话,模拟HOME键进入锁屏界面,在锁屏界面还需要模拟上划操作进入解锁界面,并在解锁界面输入正确密码进行解锁,对于这种在不同的场景(状态)的转换并作出相应处理的情景下,我可不想通过If/else来判断当前的状态,并处理,这样大大的增加了程序的耦合性,并且考虑到以后可能在打开QQ的时候,提示登录过期,那我就需要增加一个自动登录的检测和操作,为了解耦,这里使用状态机模式正好,下图是该程序的状态图:


MonitorStateMachine.png

下面简单介绍下各个状态的责任

状态 责任
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 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卡中读取该文件

关于是如何确定坐标的,其实很简单,打开开发者模式-指针位置即可查看自己实际操作时候的坐标值


pointer.png

另外为了能够执行adb命令,所以需要Root权限

最后

为了保证程序和QQ能够后台运行,所以记得添加到系统清理的白名单哦,还有如果使用的是国产ROM,最好把程序添加到系统的开启启动项,可以不需要每次重启都手动开启辅助服务
项目已经上传到Github,欢迎Start💕

效果


monitor_compress.gif


record1_compress.png


record_compress.png

附上两主子帅照


water_compress🐶.png


nida_compress🐱.png