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

吐槽

最近几个月,家里增加了两位新成员,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<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卡中读取该文件

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

pointer.png

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

最后

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

效果

monitor_compress.gif
record1_compress.png
record_compress.png

附上两主子帅照

water_compress🐶.png
nida_compress🐱.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 148,637评论 1 318
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 63,443评论 1 266
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 99,164评论 0 218
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,075评论 0 188
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,080评论 1 266
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,365评论 1 184
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 30,901评论 2 283
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,649评论 0 176
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,122评论 0 223
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,734评论 2 225
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,093评论 1 236
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,548评论 2 222
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,028评论 3 216
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,765评论 0 9
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,291评论 0 178
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,162评论 2 239
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,293评论 2 242

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,450评论 25 707
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 11,528评论 4 58
  • 在午后 秋日的阳光缓缓展开 晶莹的芽尖 如少女洁净轻盈的身体 姿态优美的舞蹈 把视线从桌面 向远方拉长 透过窗的目...
    郑州小强阅读 357评论 2 2
  • 我所理解的生活 就是和喜欢的一切在一起
    司容阅读 220评论 0 2
  • 昨天晚上和朋友聊天,从公众号的运营开始聊起,我当时有些自愧不如的说,朋友在与人沟通的能力上比我强多了。我说,自己有...
    几分趣味阅读 533评论 0 1