Android實(shí)現(xiàn)釘釘自動(dòng)打卡功能(AccessibilityService版本)
===============================================
目錄
[TOC]
為什么要做這個(gè)項(xiàng)目?
有天早晨下大雨,小編雖然出門早卻還是路上堵的遲到了,心中一句XXX崩騰而過(guò)啊,這月全勤又沒(méi)了,無(wú)奈之余想起既然技術(shù)能解決一切,那能不能搞個(gè)自動(dòng)打卡的功能(好像有點(diǎn)作弊的嫌疑...哈O(∩_∩)O哈哈~),這樣以后就不用在考慮會(huì)遲到了!于是一個(gè)邪惡的程序就誕生了.
一. 項(xiàng)目需求
項(xiàng)目功能:
- 程序啟動(dòng)后,一直后臺(tái)運(yùn)行,自動(dòng)啟動(dòng)釘釘,并進(jìn)入相應(yīng)的打卡頁(yè)面進(jìn)行打卡(需要用到模擬點(diǎn)擊功能).
- 程序的執(zhí)行時(shí)間段為上午8-9點(diǎn)為上班打卡,18-19點(diǎn)為下班打卡(時(shí)間段根據(jù)需求即可).
- 確認(rèn)打卡成功之后程序進(jìn)入休眠狀態(tài),等待下次指令.
- 程序必須24小時(shí)處于激活狀態(tài),避免被系統(tǒng)清理
項(xiàng)目流程:
二. 資源準(zhǔn)備
大致需要準(zhǔn)備以下東西:
- 一臺(tái)空閑的andorid手機(jī),能root最好.
- 下載釘釘,登陸賬號(hào)
- 手機(jī)設(shè)置充電不鎖屏,并且連接了相應(yīng)的打卡wifi.
三. 核心代碼架構(gòu)
項(xiàng)目的核心在于利用程序模擬人工打卡操作,需要用到android模擬點(diǎn)擊功能的相關(guān)api,目前比較常用的黑科技主要是以下兩種:
AccessibilityService
AccessibilityService本來(lái)是做一些輔助功能的砖茸,提供了一系列的事件回調(diào)弯洗,幫助我們指示一些用戶及界面的狀態(tài)變化袁辈,主要給殘障人群提供幫助.手機(jī)上的所有操作都會(huì)通過(guò)onAccessibilityEvent方法返回,我們可以利用該原理做到模擬點(diǎn)擊我們需要的操作程序.
不過(guò)木张,現(xiàn)在AccessibilityService已經(jīng)基本偏離了它設(shè)計(jì)的初衷,至少在國(guó)內(nèi)是這樣,越來(lái)越多的App借用AccessibilityService來(lái)實(shí)現(xiàn)了一些其它功能,甚至是灰色產(chǎn)品。
UiAutomator
基于UIAutomation的用戶界面自動(dòng)化測(cè)試框架达传,可以跨應(yīng)用工作,谷歌親生的.
UIAutomation在Android4.3發(fā)布時(shí)有了新版本迫筑,官方簡(jiǎn)介
Android4.3之前:使用inputManager或者更早的WindowsManager來(lái)注入KeyEvent
當(dāng)然,除了以上兩種,還有其他的一些能實(shí)現(xiàn)模擬點(diǎn)擊的框架,這里我就不一一贅述了,今天我們要用的就是利用AccessibilityService 輔助功能來(lái)實(shí)現(xiàn)我們的自動(dòng)打卡功能.
四. 功能實(shí)現(xiàn)
4.1 配置AccessibilityService,監(jiān)聽(tīng)手機(jī)操作
1.繼承AccessibilityService類,監(jiān)聽(tīng)手機(jī)運(yùn)行狀態(tài)信息
public class MainAccessService extends AccessibilityService {
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
//手機(jī)的所有操作信息都會(huì)通過(guò)這個(gè)方法回調(diào)
}
@Override
public void onInterrupt() {
}
@Override
protected void onServiceConnected() {
super.onServiceConnected();
}
}
2.配置AccessibilityService,創(chuàng)建accessibility_service_config.xml文件
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask" //過(guò)濾所有時(shí)間
android:accessibilityFlags="flagReportViewIds" //輔助服務(wù)額外的flag信息
android:accessibilityFeedbackType="feedbackSpoken"http://事件的反饋類型
android:notificationTimeout="100" //通知超時(shí)時(shí)間
android:canRetrieveWindowContent="true" //是否可以獲取窗口內(nèi)容
/>
3.AndroidManifest引用創(chuàng)建的配置文件(以下是配置必須)
<service android:name=".MainAccessService"
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>
4.在設(shè)置中打開(kāi)輔助功能服務(wù)
檢查輔助服務(wù)是否開(kāi)啟
private void openAccessSettingOn(){
if (!isAccessibilitySettingsOn(getApplicationContext())) {
Toast.makeText(getApplicationContext(), "請(qǐng)開(kāi)啟輔助服務(wù)", Toast.LENGTH_SHORT).show();
Intent intent = new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS);
startActivity(intent);
}
}
private boolean isAccessibilitySettingsOn(Context mContext) {
int accessibilityEnabled = 0;
// TestService為對(duì)應(yīng)的服務(wù)
final String service = getPackageName() + "/" + MainAccessService.class.getCanonicalName();
// com.z.buildingaccessibilityservices/android.accessibilityservice.AccessibilityService
try {
accessibilityEnabled = Settings.Secure.getInt(mContext.getApplicationContext().getContentResolver(),
android.provider.Settings.Secure.ACCESSIBILITY_ENABLED);
} catch (Settings.SettingNotFoundException e) {
e.printStackTrace();
}
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)) {
return true;
}
}
}
}
return false;
}
4.2 實(shí)現(xiàn)自動(dòng)化打卡流程
配置好AccessibilityService服務(wù)后,接下來(lái)我們就可以在onAccessibilityEvent方法中寫我們自動(dòng)化腳本的邏輯了.具體流程看第一節(jié)中的圖.
4.2.1 保證手機(jī)處于桌面(以下是部分核心代碼)
AccessibilityNodeInfo node=getRootInActiveWindow();
if (node == null || !Comm.launcher_PakeName.equals(node.getPackageName().toString())) {
throw new Exception("程序不在初始化啟動(dòng)器頁(yè)面,拋出異常");
}
注意上面的手動(dòng)異常和下面所有的手動(dòng)拋出異常到最后是會(huì)有大作用的,后面會(huì)講到.
4.2.2 啟動(dòng)釘釘
AccessibilityNodeInfo node=getRootInActiveWindow();
int m = 10;
while (m > 0) {
LogUtil.D("循環(huán)--" + node);
if (node != null && Comm.dingding_PakeName.equals(node.getPackageName().toString())) {
node = getRootInActiveWindow(); //刷新根頁(yè)面節(jié)點(diǎn)
LogUtil.D("已進(jìn)入app" + node);
break;
} else {
startApplication(getApplicationContext(), Comm.dingding_PakeName);
}
sleepT(1000); //1秒鐘啟動(dòng)一次
if (node != null) {
node = refshPage();
}
m--;
}
if (m <= 0) {
throw new Exception("進(jìn)入釘釘主頁(yè)異常");
}
這里我用了10次循環(huán)去嘗試啟動(dòng)釘釘,,假如10次之后都沒(méi)有進(jìn)入釘釘或者已進(jìn)入釘釘,都將拋出異常,此次腳本終止.(目的是防止出現(xiàn)啟動(dòng)時(shí)卡死,導(dǎo)致腳本也卡死)
4.2.3 判斷是否位于釘釘主頁(yè)面
通過(guò)Android SDK的uiautomatorviewer工具(在tools文件夾下,需要手機(jī)root,studio的sdk可能和elipse的不同),查看頁(yè)面的節(jié)點(diǎn)信息,如下圖:
可以得到底部絕對(duì)布局的資源id是com.alibaba.android.rimet:id/home_bottom_tab_root,而且這個(gè)id是唯一的,也就是說(shuō)我們只要找到這個(gè)節(jié)點(diǎn)的資源id,就代表已經(jīng)進(jìn)入了釘釘程序的主頁(yè)了.
具體代碼:
String resId="com.alibaba.android.rimet:id/home_bottom_tab_root";
AccessibilityNodeInfo info=getRootInActiveWindow();
List<AccessibilityNodeInfo> list = info.findAccessibilityNodeInfosByViewId(resId);
if(list==null||list.size()==0){
throw new Exception("已進(jìn)入app,未找到主頁(yè)節(jié)點(diǎn)");
}
4.2.4 進(jìn)入工作頁(yè)面
到這一步,我們程序已進(jìn)入釘釘主頁(yè),接下來(lái)需要進(jìn)入考勤打卡所在的工作頁(yè)面
在底部選項(xiàng)卡中,找到工作按鈕布局所在的資源id(com.alibaba.android.rimet:id/home_bottom_tab_button_work),點(diǎn)擊工作頁(yè)按鈕,進(jìn)入工作頁(yè),如下圖:
具體代碼
String resId="com.alibaba.android.rimet:id/home_bottom_tab_button_work";
AccessibilityNodeInfo info=getRootInActiveWindow();
List<AccessibilityNodeInfo> list = info.findAccessibilityNodeInfosByViewId(resId);
if(list==null||list.size()==0){
throw new Exception("已進(jìn)入主頁(yè),未找到工作頁(yè)按鈕");
}else{
list.get(0).performAction(AccessibilityNodeInfo.ACTION_CLICK);
}
4.2.5 已進(jìn)入工作頁(yè),查找考勤打卡按鈕,進(jìn)行點(diǎn)擊操作,進(jìn)入考勤打卡頁(yè)面
到這一步,我們程序默認(rèn)已經(jīng)在工作頁(yè)面了,接下來(lái)需要做的就是點(diǎn)擊考勤打卡選項(xiàng),進(jìn)入考勤頁(yè)面.
這里有些許的復(fù)雜,因?yàn)椴荒苤苯诱业娇记诖蚩ㄋ诓季值膇d,只能先查找其所在的父布局的id(com.alibaba.android.rimet:id/oa_fragment_gridview),然后再找到考勤打卡的節(jié)點(diǎn).
具體代碼:
String resId="com.alibaba.android.rimet:id/oa_fragment_gridview";
AccessibilityNodeInfo info=getRootInActiveWindow();
List<AccessibilityNodeInfo> list = info.findAccessibilityNodeInfosByViewId(resId);
if(list!=null||list.size()!=0){
AccessibilityNodeInfo node = list.get(0);
if (node != null || node.getChildCount() >= 8) {
node = node.getChild(7);
if (node != null) { //已找到考勤打卡所在節(jié)點(diǎn),進(jìn)行點(diǎn)擊操作
node.performAction(AccessibilityNodeInfo.ACTION_CLICK);
}else{
throw new Exception("已進(jìn)入工作頁(yè),但未找到考勤打卡節(jié)點(diǎn)");
}
}else{
throw new Exception("已進(jìn)入工作頁(yè),但未找到考勤打卡節(jié)點(diǎn)");
}
}else{
throw new Exception("已進(jìn)入工作頁(yè),但未找到相關(guān)節(jié)點(diǎn)");
}
4.2.6 確認(rèn)已考勤打卡頁(yè)面
到這一步,我們程序認(rèn)為已經(jīng)進(jìn)入了考勤打卡頁(yè)面了,接下來(lái)我們需要再確認(rèn)一下目前所在節(jié)點(diǎn)是不是考勤打卡頁(yè)面的節(jié)點(diǎn).
這個(gè)頁(yè)面是一個(gè)webview頁(yè)面,所以判斷是否已進(jìn)入考勤打卡界面,我們只要找到了webview布局的一個(gè)唯一資源id標(biāo)識(shí)即可(com.alibaba.android.rimet:id/webview_frame),
代碼:
String resId="com.alibaba.android.rimet:id/webview_frame";
AccessibilityNodeInfo info=getRootInActiveWindow();
List<AccessibilityNodeInfo> list = info.findAccessibilityNodeInfosByViewId(resId);
if(list==null||list.size()==0){
throw new Exception("進(jìn)入考勤打卡頁(yè)面異常");
}
4.2.7 執(zhí)行打卡操作
到這一步,程序已確認(rèn)進(jìn)入考勤打卡頁(yè)面,可以開(kāi)始執(zhí)行打卡操作.按照我們一些的步驟,打卡操作只需要你找到相應(yīng)的打卡按鈕節(jié)點(diǎn),然后通過(guò)節(jié)點(diǎn)的點(diǎn)擊操作接口,但是很不幸的是,由于考勤打卡頁(yè)面時(shí)webview頁(yè)面,我們不能定位到詳細(xì)的打卡按鈕所在的節(jié)點(diǎn)(準(zhǔn)確來(lái)說(shuō)有時(shí)可以,有時(shí)不可以,而且這情況發(fā)生在同一臺(tái)手機(jī)上,差點(diǎn)把小編折騰死,只能用最壞情況操作了),因?yàn)槲覀兏菊也坏剿馁Y源id,我們唯一能找到的只能是他的父節(jié)點(diǎn)(com.alibaba.android.rimet:id/webview_frame),然后并沒(méi)卵用!
不過(guò)方法總是有的!
既然我們不能定位節(jié)點(diǎn),但我們可以定位坐標(biāo)啊,剛好tap命令可以模擬點(diǎn)擊屏幕坐標(biāo)!!!瞬間感覺(jué)自己是個(gè)天才!!
我們只需要找到上班打卡和下班打卡兩個(gè)按鈕所在的坐標(biāo)(不同分辨率的手機(jī)會(huì)有不同),然后使用adb命令直接模擬點(diǎn)擊即可!
點(diǎn)擊坐標(biāo)方法
public static void clickXy(String x,String y){
String cmd = "input tap "+x+" "+y ;
try {
execRootCmdSilent( cmd);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 執(zhí)行命令但不關(guān)注結(jié)果輸出
*/
private static int execRootCmdSilent(String cmd) {
int result = -1;
DataOutputStream dos = null;
try {
Process p = Runtime.getRuntime().exec("su");
dos = new DataOutputStream(p.getOutputStream());
dos.writeBytes(cmd + "\n");
dos.flush();
dos.writeBytes("exit\n");
dos.flush();
p.waitFor();
result = p.exitValue();
} catch (Exception e) {
e.printStackTrace();
} finally {
if (dos != null) {
try {
dos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return result;
}
4.2.8 確定打卡成功
模擬點(diǎn)擊了打卡界面之后,如果操作成功,默認(rèn)會(huì)出現(xiàn)一個(gè)打卡成功的彈窗,我們可以根據(jù)這個(gè)彈窗來(lái)判斷是否打卡成功
由于這個(gè)彈窗也不能找到相關(guān)的id的詳細(xì)節(jié)點(diǎn),而且也不能通過(guò)text去查找,所以這里先通過(guò)遞歸方法拿到所有的幾點(diǎn),然后判斷每個(gè)節(jié)點(diǎn)的content-desc是否包含打卡成功的字樣,如果有,我們就默認(rèn)打卡成功!
首先找出所有節(jié)點(diǎn)
//遞歸獲取所有節(jié)點(diǎn)
private List<AccessibilityNodeInfo> getAllNode(AccessibilityNodeInfo node, List<AccessibilityNodeInfo> list) {
if (list == null) {
list = new ArrayList<>();
}
if (node != null && node.getChildCount() != 0) {
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo info = node.getChild(i);
if (node != null) {
list.add(info);
node = info;
}
}
} else {
return list;
}
return getAllNode(node, list);
}
判斷節(jié)點(diǎn)是否包含打卡成功字樣
//檢查是否打卡成功
AccessibilityNodeInfo node = getRootInActiveWindow();
//查詢所有的根節(jié)點(diǎn),假如有彈窗,說(shuō)明打卡成功
List<AccessibilityNodeInfo> list = getAllNode(node, null);
LogUtil.D("所有節(jié)點(diǎn)個(gè)數(shù)-->" + list.size());
if (list != null) {
for (AccessibilityNodeInfo info : list) {
String className = info.getClassName().toString();
if ("android.app.Dialog".equals(className)) {
//說(shuō)明可能是打卡導(dǎo)致的成功彈窗
AccessibilityNodeInfo nodeInfo = info.getChild(0);
if (nodeInfo != null) {
nodeInfo = nodeInfo.getChild(1);
if (nodeInfo != null) {
String des = nodeInfo.getContentDescription().toString();
if (des.contains("打卡成功")) {
//這里做你想做的事,比如發(fā)個(gè)郵件通知一下
return;
}
}
}
}
}
}
每次模擬點(diǎn)擊之后,都要判斷一下是否有打卡成功彈窗,最多嘗試10次
//已進(jìn)入打卡頁(yè)面,執(zhí)行打卡操作
int j = 10;
while (j >= 0) {
LogUtil.D("嘗試打卡操作->" + j);
if(DoDaKa(order)){ //這里封裝了一下,這是模擬點(diǎn)擊之后,判斷彈窗打卡成功的方法
//這里可以發(fā)送郵件
return;
}
sleepT(2000);
j--;
}
4.2.9 異常處理
在上述流程中,基本每一步都拋出了大量異常,出現(xiàn)異常,即代表程序沒(méi)有按照我們?cè)O(shè)定的流程走,這時(shí)我們就需要去修正.一旦出現(xiàn)異常,我們讓腳本回到初始狀態(tài),也就是最初的桌面狀態(tài).android可以通過(guò)回退鍵來(lái)恢復(fù)到桌面.
代碼:
//程序異常時(shí)的操作方法
private void AppCallBack() {
int i = 10; //最多嘗試10次回退操作
while (true) {
//執(zhí)行回退操作
AccessibilityNodeInfo node = getRootInActiveWindow();
if (i < 0) { //10次還未到桌面
//說(shuō)明可能卡住了,無(wú)法回退,強(qiáng)行停止程序進(jìn)程
CMDUtil.stopProcess(node.getPackageName().toString());
break;
}
LogUtil.D("執(zhí)行回退操作");
performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK);
if (node != null && Comm.launcher_PakeName.equals(node.getPackageName().toString())) {
//已回退到啟動(dòng)頁(yè),退出循環(huán)
LogUtil.D("桌面");
break;
}
i--;
sleepT(1000); //睡眠一秒
}
}
TIPS:
上溯所有流程的每一步,我們最好都加上1-2秒的延遲時(shí)間,畢竟頁(yè)面跳轉(zhuǎn)是需要時(shí)間的,對(duì)于手機(jī)性能差的手機(jī)相應(yīng)的時(shí)間可以再延遲一些.
五. 功能測(cè)試
到這里,我們的自動(dòng)打卡程序基本就已經(jīng)實(shí)現(xiàn)了,當(dāng)然,上面只是實(shí)現(xiàn)自動(dòng)打卡的核心代碼.還有很多的拓展空間,比如可以加上一個(gè)任務(wù)請(qǐng)求線程,實(shí)現(xiàn)在特定時(shí)間,來(lái)實(shí)現(xiàn)打上班卡還是打下班卡,以及打卡成功之后及時(shí)的郵件通知到手機(jī)上.也可以通過(guò)服務(wù)器來(lái)定時(shí)啟動(dòng)程序,控制腳本程序啥時(shí)候運(yùn)行,啥時(shí)候不運(yùn)行.發(fā)揮你的想象吧!
分割線
2018-07-31更新
測(cè)試發(fā)現(xiàn),4.2.8步驟監(jiān)測(cè)打卡成功的節(jié)點(diǎn)計(jì)算有時(shí)會(huì)出現(xiàn)大量的復(fù)雜節(jié)點(diǎn),極大的增加了程序的負(fù)擔(dān).
由于釘釘打卡成功會(huì)有通知,我們可以監(jiān)聽(tīng)手機(jī)的通知欄來(lái)判斷程序是否打卡成功,這種方式更加的輕量快捷!
分割線
2019-02-15更新
很多童鞋私信要源碼,現(xiàn)體統(tǒng)github地址