钉钉自动拍照打卡 App 的实现

Autumn from Bascom Hill in Madison, Wisconsin

每天上下班使用钉钉拍照打卡是个很烦人的事情,因为我经常会忘记打卡。而且每天要打开手机操作两次,这么机械化的事情,作为一个安卓开发工程师,难道就没有什么办法可以把它给自动化吗?答案当然是 Yes, we can!

00 实现方式的探讨

首先上网搜索了一番,果然我不是第一个觉得打卡这件事很麻烦的人。发现钉钉打卡其实还分很多种,比如公司 WIFI 打卡,如果你们使用的是这种打卡方式,那么钉钉其实是有一个快速打卡(相当于自动打卡)的功能,只要管理员开启这个功能就可以了,我们也就不需要折腾了。但是,很显然我们拍照打卡是不可能使用这种方案来解决的,于是又看了几篇拍照打卡的文章,发现实现方案大致分为两种:1)使用钉钉打卡的接口;2)模拟屏幕点击完成打卡。

第一种方案需要抓包获取到钉钉的打卡请求的接口,然后我们只要到点按时发起请求就完成打卡了,这种方案感觉难度比较高,而且请求接口也有可能发生变化,所以果断 PASS 了。第二种方案模拟屏幕点击,想了想感觉还是比较靠谱的,也比较符合我们的需求,我们只要把拍照打卡这一系列的点击屏幕的操作规划好就行了。于是乎,撸起袖子开干!

01 初步尝试

一开始,我参考了这位博主的方案

监听闹钟广播,闹钟响起的时候,解锁,并打开钉钉 App,然后模拟屏幕点击,滑动等操作,最后关闭钉钉。

看似很完美的方案对不对,但是也有问题,那就是在我的 Nexus 6P (Android 8.0) 上,根本就没办法监听到闹钟广播好吗!查了下发现,8.0 以后隐式广播被禁止了,真是非常抱歉呢!

不过也有一些例外,看这里:Implicit Broadcast Exceptions,只有这些列出的隐式广播还是可以继续在 manifest 里注册的。看吧,谷歌爸爸也不是那么霸道呢~

既然闹钟广播监听不到,那就监听可以监听到的呗,比如:

1
2
3
IntentFilter filter = new IntentFilter();
filter.addAction(Intent.ACTION_TIME_TICK);
registerReceiver(new AutoStartReceiver(), filter);

Intent.ACTION_TIME_TICK 是由系统发出的时间变化的广播(闹钟响起的时候同时也会触发),触发后每分钟发送一次。所以我们可以监听这个广播然后判断当前时间是否是打卡时间,是则执行打卡,否则就忽略。

这种方案经过我的初步尝试后的确可行,但是设置闹钟难免有点麻烦。后来想了想,既然只是自己用,那么干脆就用比较暴力的手段实现:保持 Service 运行,时间一到就执行打卡,嗯~(•̀ᴗ•́)و ̑̑

02 实现策略

首先说明下,我的实现方案肯定不是最好的,而且一些编码的方式也不推荐在实际的项目中使用。

我们使用一个 KeepRunningService 来保证应用始终能够在后台运行,最好把应用加入到 Greenify 的白名单中。Service 中只做一件事,注册监听 Intent.ACTION_TIME_TICK 的广播,然后把这个 Service 设置成前台 Service 并每 5 分钟运行一次来达到保持后台运行的目的。

通过 PunchReceiver 来运行 PunchServicePunchService 就是实际进行打卡操作的地方,我们使用 IntentService 来实现。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class PunchService extends IntentService {

// ...

/**
* 处理打卡,利用 adb 命令点击屏幕完成打卡
* 不同屏幕坐标位置不同,可以在开发者选项中开启查看屏幕坐标:Developer options -> Input -> Pointer location
*/
@Override
protected void onHandleIntent(@Nullable Intent intent) {
if (!timeForPunch()) {
stopSelf();
return;
}

Log.d(this.getClass().getSimpleName(), "onHandleIntent: start punching...");

// 唤醒屏幕
wakeUp();
// 上滑解锁
swipe("720", "2320", "720", "1320");
SystemClock.sleep(1000);

// 输入 PIN 码解锁
inputPinIfNeeded();
SystemClock.sleep(3000);

showToast("打开钉钉");
startAppLauncher(DD_PACKAGE_NAME);
SystemClock.sleep(10000);

showToast("点击中间菜单");
clickXY("700", "2325");
SystemClock.sleep(5000);

showToast("点击考勤打卡");
clickXY("540", "1800");
SystemClock.sleep(10000);

showToast("点击打卡");
clickXY("700", punchPositionY);
SystemClock.sleep(5000);

showToast("点击拍照");
clickXY("710", "2280");
SystemClock.sleep(8000);

showToast("点击 OK");
clickXY("710", "2281");
SystemClock.sleep(5000);

showToast("退出钉钉");
stopApp(DD_PACKAGE_NAME);

startAppLauncher(getPackageName());
SystemClock.sleep(3000);

// 更新 UI
String currentTime = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA).format(new Date());
EventBus.getDefault().post(new PunchFinishedEvent(punchType, currentTime));
Log.d(this.getClass().getSimpleName(), "onHandleIntent: punch finished");

close();

stopSelf();
}
}

以上是核心代码,点击以及滑动操作都是通过 adb 命令完成的(需要 root 权限):

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
31
/**
* 滑动屏幕
*/
public static void swipe(String x1, String y1, String x2, String y2) {
String cmd = String.format("input swipe %s %s %s %s \n", x1, y1, x2, y2);
exec(cmd);
}

/**
* 点击
*/
public static void clickXY(String x, String y) {
Log.d(AppUtil.class.getSimpleName(), "clickXY: " + x + ", " + y);
String cmd = String.format("input tap %s %s \n", x, y);
exec(cmd);
}

/**
* 执行 ADB 命令
*/
public static void exec(String cmd) {
try {
if (os == null) {
os = Runtime.getRuntime().exec("su").getOutputStream();
}
os.write(cmd.getBytes());
os.flush();
} catch (Exception e) {
e.printStackTrace();
}
}

打卡完成后会提示,而且主页面提供手动打卡的方式,基本上和自动打卡操作是一样的,点击后会直接打开钉钉进行打卡,以防如果自动打卡不起作用(服务被杀死)的情况。

项目地址:aJIEw/AutoPunchDing

当然这个实现方式还是很原始的,如果想要使用的话还是得自己改代码,比如点击位置以及打卡时间。另外,闹钟其实可以不设置,因为只要应用不被杀死就可以自动打卡,但是最好还是设置一下,因为根据我一个礼拜下来的使用体验来看,偶尔还是会不能自动打卡的。最理想的方式是快到打卡时间的时候把应用打开一下,但是要是能记得就不需要这个 app 了,所以还是设闹钟吧(•̀ᴗ•́)و ̑̑


参考文章:钉钉自动打卡的一种实现