[TOC]

背景

公司安全政策限制,上班期间不能拍照/录像。虽然已经差不多养成习惯,但老虎保不住也有打盹的时候,曾经有一次就差点违规。所以期望有一款 app 能代为管理,上班期间拍照/录像的时候会自动提示或者功能不可用,下班期间自动放开限制。

方案设计

可选方案 1:接收 NEW_PICTURE 事件广播

首先自然而然地想到广播,相机拍照时系统会发出 action 为 com.android.camera.NEW_PICTURE 的广播,可以创建一个接收器在接收到这个广播时提示不允许拍照。AndroidManifest.xml 文件中对广播接收器做静态注册,如下:

<!-- AndroidManifest.xml -->

<receiver
          android:name=".CameraEventReceiver"
          android:enabled="true">
  <intent-filter>
    <action android:name="com.android.camera.NEW_PICTURE" />
  </intent-filter>
</receiver>

问题是,这里的 NEW_PICTURE 是点击拍照按钮产生的广播事件,找了一圈没有找到打开相机产生的广播事件,这时拍照已成既定违规事实了,不能起到事前/事中提醒或禁止作用,不符合要求。

可选方案 2:CameraManager 的 onCameraUnavailable 回调

CameraManager 是系统服务之一,专门用于 检测打开相机以及获取相机设备特性。可以使用 onCameraUnavailable 回调函数在相机被占用时(意味着打开了相机)进行提醒。核心代码如下:

CameraManager manager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);
manager.registerAvailabilityCallback(new CameraManager.AvailabilityCallback() {
  @Override
  public void onCameraUnavailable(String cameraId) {
    super.onCameraUnavailable(cameraId);
    Log.i(TAG, "camera unavailable");
    // 提示相机不可用等弹框
}

功能上没问题,只是要保证服务一直在后台运行,才能在需要的时候提醒用户。其实安装这个 App 只是起到辅助提醒作用,真正需要提醒用户的概率很小很小,一直让 App 在后台运行太浪费手机资源。而且这个提醒功能只能做到事中提醒,能不能直接把相机直接禁用掉,事前防范,让用户没有犯错的机会呢。

可选方案 3(最终方案):Android Device Administration API 禁用相机功能

Android Device Administration API 是 Android 用来提供企业应用支持的,通过 API 可以在系统级别提供密码管理、停用相机等设备管理功能。通过调用 API,打开相机时给予以下错误提示:

再配合闹钟(AlarmManager)实现定时开关,这恰恰就是我想要的,完美!

主要实现

1. 启用 Device Administration API (MainActivity)

DevicePolicyManager devicePolicyManager = (DevicePolicyManager) getSystemService(Context.DEVICE_POLICY_SERVICE);
ComponentName deviceAdmin = new ComponentName(this, MyDeviceAdminReceiver.class);

if (!(devicePolicyManager.isAdminActive(deviceAdmin))) {
  // 启用Device Admin API
  startActivateDeviceAdminActivityForResult();
} else {
  CameraUtil.blockOrUnblockCameraNow(this, amStartWorkTime, amStopWorkTime, pmStartWorkTime, pmStopWorkTime);
}

private void startActivateDeviceAdminActivityForResult() {
  Intent intent = new Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN);
  intent.putExtra(
    DevicePolicyManager.EXTRA_DEVICE_ADMIN,
    deviceAdmin);
  intent.putExtra(
    DevicePolicyManager.EXTRA_ADD_EXPLANATION,
    getString(R.string.admin_explanation));
  startActivityForResult(intent, REQUEST_ENABLE);
}

2. 根据当前是否在工作时间段,发送启用/禁用相机广播

public class CameraUtil {
  public static void blockOrUnblockCameraNow(Context context, int amStartWorkTime, int amStopWorkTime, int pmStartWorkTime, int pmStopWorkTime) {
    Intent intent = new Intent();
    intent.setFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES);
    intent.setComponent(new ComponentName("com.aniu.cameramanager","com.aniu.cameramanager.AlarmReceiver"));
    if (WorkTime.isNowWorkTime(amStartWorkTime, amStopWorkTime, pmStartWorkTime, pmStopWorkTime)) {
      context.sendBroadcast(intent.setAction("ACTION_BLOCK_CAMERA"));
    } else {
      context.sendBroadcast(intent.setAction("ACTION_UNBLOCK_CAMERA"));
    }
  }
}

3. 根据广播启用/禁用相机 ,并设置下一次的闹钟 (AlarmReceiver)

public void onReceive(Context context, Intent intent) {
  if (action.equals("ACTION_BLOCK_CAMERA")) {
    devicePolicyManager.setCameraDisabled(deviceAdmin, true);
    Log.i(TAG, "禁用相机成功, action: " + action);

    // 设置下一次的闹钟
    setCameraAlarm(context, getNextCameraAlarm());
  } else if (action.equals("ACTION_UNBLOCK_CAMERA")) {
    devicePolicyManager.setCameraDisabled(deviceAdmin, false);
    Log.i(TAG, "启用相机成功, action: " + action);

    // 设置下一次的闹钟
    setCameraAlarm(context, getNextCameraAlarm());
  }
}

private void setCameraAlarm(Context context, Alarm alarm) {
  Intent intent = new Intent(context, alarm.getAlarmReceiver());
  intent.setAction(alarm.getAction());
  intent.setFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES);
  intent.setComponent(new ComponentName("com.aniu.cameramanager","com.aniu.cameramanager.AlarmReceiver"));
  PendingIntent alarmIntent = PendingIntent.getBroadcast(context, 999, intent, 0);
  Calendar calendar = alarm.getCalendar();

  AlarmManager alarmManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
  alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, calendar.getTimeInMillis(), alarmIntent);
  Log.i(TAG, "设置闹钟成功: " + alarm.getCalendar().getTime() + " " + alarm.getAction());
}

实现过程中的坑点和对应处理

  1. 原计划使用 AlarmManager 的循环闹钟,但目前(2021-6-8)情况下循环闹钟的事件都是不精确的,在华为畅享 9(我的主力测试机)上实测差几分钟/几十分钟的情况都存在,所以使用了单个闹钟AlarmManager.setExactAndAllowWhileIdle方法,低功耗下也可以准确执行。方法的第一个参数使用AlarmManager.RTC_WAKEUP,即以系统时间为参照。

  2. 广播定向发送

在很多手机上应用发送广播需要申请权限或者特殊处理,不然会接收不到广播,这里设置成了定向广播:

intent.setComponent(new ComponentName("com.aniu.cameramanager","com.aniu.cameramanager.AlarmReceiver"));
  1. 重启处理

手机重启后需要让应用保持运行,需要在 AlarmReceiver 中同时监听开机事件。

if (action.equals("android.intent.action.BOOT_COMPLETED")) {
  Log.i(TAG, "监听到重启完成事件, action: " + action);

  // 重启后根据当前时间段禁用/启用相机
  CameraUtil.blockOrUnblockCameraNow(context, amStartWorkTime, amStopWorkTime, pmStartWorkTime, pmStopWorkTime);
} 

同时在 manifest 文件中 receiver 的 intent-filter 中增加 action android:name="android.intent.action.BOOT_COMPLETED" 。

  1. 保持后台运行

如果不把应用设置成可后台运行,监听开机事件拉起 receiver 后很快应用进程就会被系统杀掉,需要提供入口或引导用户设置应用保持后台运行。

  1. 防止被意外强行终止

很多手机用户喜欢清理最近使用的应用,这里在 manifest 文件中设置android:excludeFromRecents="true"属性来规避。

待完善

增加临时启用相机入口

虽然程序中增加了当天是否为工作日的判断,节假日不会去停用相机,但如果用户休假,那在原工作时间段内相机还是会停用,导致用户没办法使用相机。需要在提示用户相机被停用的界面增加临时启用相机入口,不妨碍用户正常使用相机。

目前还不知道怎么修改这个界面,在 stackoverflow 上面提了问题,还没人回复。

完整源码

https://gitee.com/teng-aniu/CemaraManager


↙↙↙阅读原文可查看相关链接,并与作者交流