坑娃-防沉迷App

猿爸:屁孩,已经看了很长xx了,该让眼睛休息下了。
屁孩:不情不愿的关了,去玩别的。
很和谐是不是?当你不在家的时候,屁孩就没这么乖了,是不是,认同不?
这时就需要有一个强势的“人”来阻止他,这个可别奢望老人来阻止他,于是只能让“机器人”来搞了。

于是搞了个App,名字叫坑娃神器(不知道若干年后,屁孩看到这个文章心里是什么滋味。。。),功能比较简单,就是监控指定app的活动状态,如果连续运行一段时间,坑娃神器就会自动从后台切到前台进行倒计时,倒计时结束之后可以继续娱乐。

这个App目前只是处于原始社会版,希望有能力的猿爸们加入,我们一起将其完善,帮助下一代养成一个好的自律能力,此App已在github上开源,欢迎贡献,项目地址https://github.com/hunshenshi/timeup.git

调研–准备工作

找了下目前市面已有的工具和一些手机自带的健康使用的功能,不太满足我的需求,
1、大多数都是限制每天累计使用时长,而我希望每使用一段时间就休息一会,而不关注累计时长
2、由于市面上都是一些面向广大用户的产品,达到时长之后会有提示,而不会有更激烈的行为。而我的需求强硬,再强硬(反正用户只有家长的娃)

我想要的功能也比较简单,就是当检测到前台视频类的软件连续运行一段时间之后,依然在运行,就将其切换到后台,并将倒计时页面切换至前台,等待倒计时结束。随后再次进行下一轮监控,不监控累计时长,只监控连续使用时长,并且强制切换App。

没有现成的轮子那就自己造一个吧,梳理下会用到的技术点:
1、App保活,因为需要坑娃神器长期存活于后台进行监控
2、如何拿到正在运行的app
3、定时执行,需要坑娃神器定期检测前台运行的app是什么
4、后台唤醒,需要坑娃神器从后台唤醒到前台进行倒计时

看了些相关的文章,感觉功能大都可以实现,只是安卓的版本太多了,各种功能在各个版本中实现方式又不一样,在这特别同情搞安卓的朋友,真不容易呀。。。

第一个Android Application

决定要搞了,那就先从Hello World开始吧,首先下载个Android Studio,然后根据向导创建一个android应用,这里语言我选的是java,一路下来就能运行一个demo app了。具体步骤如下:
新建project
新建project之后,进入选择模版页面,这里我选的是basic,下面还有很多模版可以选
选择project
选择下一步之后,填写project相关的信息,比如project名字,需要的语言和sdk
填写project信息
点击Finish就结束了,等待IDE加载程序就行了。

这个过程可能会比较慢,主要是gradle-5.6.4-all.zip下载慢,你可以使用迅雷下载,然后放到指定的目录就OK了。

App保活

App保活这个话题,不搜不知道,一搜是打开眼界呀,充分展现了群众的智慧呀。什么双Activity相互唤醒、播放无声音频各种奇淫巧技。
不过我这里就自己用,这么多的奇淫巧技我是用不上了(就算需要用上我也没有那么高深的功力),规规矩矩的申请权限吧。

常驻后台需要申请电池白名单权限,网上有很多代码示例,相关代码如下:

1
2
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
context.startActivity(intent);

获取正在运行的App

如何通过代码拿到正在前台展示的app,也是困难重重,对于一无所知的我依然是先网上搜一堆demo,然后测试。
搜素的结果大体分为3种,主要分布在不同的sdk版本中,已经过时的有getRunningTasksgetRunningAppProcesses,在新版sdk中都普遍使用UsageStatsManager,使用UsageStatsManager时需要申请权限,而且通过它获取app的方法网上也有好几种,我对比了几种使用的是queryUsageStats方法,还可以使用queryEvents方法,不过我测试发现queryEvents状态变更没有queryUsageStats及时。(不要问我为什么测试了这么多方法。。)

整体思路是通过queryUsageStats查询一段时间内的App使用信息,然后对App的最后更新时间进行排序,时间最大的就是正在前台展示的App。代码示例如下:

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
// 获取UsageStats集合
public static List<UsageStats> getUsageStatsList(Context context, long startTime, long endTime) {
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
UsageStatsManager manager = (UsageStatsManager) context.getApplicationContext().getSystemService(Context.USAGE_STATS_SERVICE);
List<UsageStats> usageStatses = manager.queryUsageStats(UsageStatsManager.INTERVAL_BEST, startTime, endTime);
if (usageStatses == null || usageStatses.size() == 0) {// 没有权限,获取不到数据
Log.i("AppUtil getUsageStatsList ", "no permission for UsageStatsManager");
return null;
}
return usageStatses;
}
return null;
}

// 对最后更新时间进行排序获取前台App
private static UsageStats getForegroundUsageStats(Context context, long startTime, long endTime) {
UsageStats usageStatsResult = null;
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
List<UsageStats> usageStatses = getUsageStatsList(context, startTime, endTime);
if (usageStatses == null || usageStatses.isEmpty()) {
return null;
}

for (UsageStats usageStats : usageStatses) {
// 通过反射获取App的事件类型
int lastEvent = 1;
try {
Field mLastEventField = UsageStats.class.getField("mLastEvent");
mLastEventField.setAccessible(true);
lastEvent = mLastEventField.getInt(usageStats);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}

if (usageStatsResult == null || usageStatsResult.getLastTimeUsed() < usageStats.getLastTimeUsed()) {
usageStatsResult = usageStats;
}
}
}
return usageStatsResult;
}

定时周期执行任务

由于这只是个坑娃的App,所以这里直接简单粗暴的使用定时任务来check前台App是否为所要监控的App,而没有使用各种Trigger。
Android中的定时任务主要是TimerAlarmManager,推荐使用AlarmManager,而且不需要申请权限。

AlarmManager结合Service使用,在高版本的sdk中,AlarmManager没有重复执行的功能,需要在Service再次调用AlarmManager从而起到重复执行任务的功能。而且在高版中为了节省电量也进行了很多优化,要想精准出发定时器需要使用setExactAndAllowWhileIdle方法,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
// CheckService是定时器触发之后要具体执行的任务逻辑
Intent intent = new Intent(context, CheckService.class);
pendingIntent = PendingIntent.getService(context, 0, intent, 0);
// AlarmManager.ELAPSED_REALTIME_WAKEUP是为了能主动唤醒CPU
am.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime(), pendingIntent);

// 在CheckService的onStartCommand方法中执行定时任务逻辑,并且执行完之后继续设置定时器,起到重复执行的效果
public class CheckService extends Service {
public int onStartCommand(Intent intent, int flags, int startId) {
// check前台App是否为需要监控的App,如果是执行相应的操作
// 再次调用定时器
am.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + TIME_INTERVAL, pendingIntent);
}
}

注意两次调用定时器的不同,在CheckService中调用时Trigger为SystemClock.elapsedRealtime() + TIME_INTERVAL,代表延后TIME_INTERVAL之后触发。

后台唤醒

后台唤醒是指正在后台运行的App由于某种原因被切换到前台。

如果是在低版本的sdk中可以使用getRunningTasks,拿到对应package的task信息,其中包括taskId,然后调用moveTaskToFront方法将其任务切换到前台,但是getRunningTasks已被标注为过时的,所以在上面获取前台App时也没用该方法。

这里使用了新建Activity的方法,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void toRunningForeground(String packageNameTarget) {
PackageManager packageManager = getPackageManager();

Intent intent = packageManager.getLaunchIntentForPackage(packageNameTarget);
intent.addCategory(Intent.CATEGORY_LAUNCHER);
intent.setFlags(Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED | Intent.FLAG_ACTIVITY_NEW_TASK);

/**android.intent.action.MAIN:打开另一程序
*/
intent.setAction("android.intent.action.MAIN");
/**
* FLAG_ACTIVITY_SINGLE_TOP:
* 如果当前栈顶的activity就是要启动的activity,则不会再启动一个新的activity
*/
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
activity.startActivity(intent);
}

踩坑实录

主要功能开发完成,并在IDE中正常运行,下面就要实机测试了,前方冰冷的洪水迎面拍来!!!

将App安装到平板上之后,进行测试发现应该切换倒计时页面时并没有正常切换,于是下面就开始了取经之后:

  1. sdk版本不兼容
    因为Android sdk各版本有可能不兼容,所以首先怀疑是不是sdk的问题,于是在IDE中设置了对应版本,发现确实没有生效,于是搜索对应sdk后台切换前台的相关问题,发现可能是权限问题,sdk 29之后限制了App的相应权限,需要申请浮窗权限,申请代码如下:
1
2
3
4
public void requestOverlayPermission(Context context) {
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);
context.startActivity(intent);
}
  1. 获取当前App
    申请相应权限之后,在IDE中可以正常运行,再次部署到平板上进行测试,发现依然没有生效,继续排查。
    在IDE中多次排查时偶然发现获取当前App时,有次拿到了com.xx.android.launcher,以为是获取前台App不准确原因造成的,于是就用了好多种方法进行测试,发现始终未生效。但是在真机测试的过程中,发现一个规律,只要插上usb,App就运行正常,在IDE中查看App在平板上的运行日志也正常,可是拔掉usb就不生效,也看不到当时的日志,于是就想打印些关键信息在平板上查看进行排查问题。

  2. 真机上查看运行日志
    看一些日志教程好复杂,专门搞一下真的不值得呀,于是想到是不是可以把关系信息写到一个可读的文件里,于是将其写入,代码如下:

    1
    2
    3
    4
    5
    public static void writeFile(Context context, String filename, String filecontent) throws IOException {
    FileOutputStream output = context.openFileOutput(filename, Context.MODE_APPEND);
    output.write(filecontent.getBytes());
    output.close();
    }

使用时只传入文件名就行,默认写到/data/data/app/com.xx/files目录下,去平板上找这个目录,却没有找到。。。不过连上usb可以在IDE的Device File Explorer中找到,于是再次的测试流程就是拔掉usb进行测试,然后连上usb进行文件查看运行信息。

  1. 怀疑AlarmManger在后台不生效
    查看App写入文件的信息之后,发现定时器只是第一次启动了,随后的定时器并未触发。
    在使用AlarmManger时也没有要求申请什么权限,搜索之后也没有找到任何线索,于是资源一些专业人士,朋友说可能是定时器触发间隔时间太短,系统给优化了,并附上了源码中的方法注释,可信度很高呀,我也很开心,要搞定了,可是修改安装之后依然是暴击呀。

此时真的陷入了死胡同,没有任何线索,一连好几天没有任何进展,偶然在手机上查看应用权限的时候,想到是不是平板自动启停的原因,于是将App的自动管理切换为手动管理,再次进行测试,生效了。

太艰辛了,为了坑个娃太不容易了。。。

这个App目前只是处于原始社会版,希望有能力的猿爸们加入,我们一起将其完善,帮助下一代养成一个好的自律能力,此App已在github上开源,欢迎贡献,项目地址https://github.com/hunshenshi/timeup.git

后记

其实写这个App并不完全是为了完全阻止他玩平板,只是为了培养他解决问题,善于思考的能力,让他知道如何去达到自己的目的。因为这个App本身比较简单,可能只能阻止他几天,然后他会发现这个App的逻辑漏洞,然后我再想办法把这个漏洞堵上,继续等待他发现新的漏洞,希望在这个过程中能培养他的好奇欲。

您的肯定,是我装逼的最大的动力!