ACTION_NEW_PICTURE・ACTION_NEW_VIDEO を JobScheduler で置き換える

# この記事について
Android N では ACTION_NEW_PICTURE と ACTION_NEW_VIDEO を使ったカメラ撮影の検知ができなくなりました。

この記事では代替手段として提示されている JobScheduler を使った検知の実装について説明します。

# Android N での変更点
バックグラウンド処理の最適化のために、Android N で動作するアプリは次の二つのブロードキャストインテントを送受信できなくなりました。

- ACTION_NEW_PICTURE
- ACTION_NEW_VIDEO

これはアプリのターゲット SDK のバージョンによらないことに注意が必要です。すなわち、このインテントに依存している現在リリース中のアプリは Android N 上で動作しない可能性があります。
https://developer.android.com/about/versions/nougat/android-7.0-changes.html?hl=ja#bg-opt

これらに代わる手段として、公式ドキュメントでは JobScheduler を使う方式が提示されています。
https://developer.android.com/topic/performance/background-optimization.html

# サンプルアプリの実装
JobScheduler API を使ってカメラで撮影した画像/動画を検知するサンプルアプリを作成しました。
撮影した画像の Content URI を ADB ログに出力するアプリです。
(Android N 未満では BroadcastReceiver で検知するようにしてあります）
https://github.com/hshiozawa/JobSchedulerSampleForContentURI

以下では実装のポイントについて説明します。

## Job の宣言
AndroidManifest.xml にジョブクラスを宣言します。
このアプリで利用するジョブは CameraJobService クラスですので、次のように宣言します。

```xml:AndroidManifest.xml
...
<service
    android:name=".CameraJobService"
    android:exported="true"
    android:permission="android.permission.BIND_JOB_SERVICE" />
...
```
https://github.com/hshiozawa/JobSchedulerSampleForContentURI/blob/master/app/src/main/AndroidManifest.xml


AndrroidManifest.xml でクラスを宣言しないと、OS がジョブを認識できないため実行時にエラーとなります。

```
java.lang.IllegalArgumentException: No such service ComponentInfo{com.hjm.jobschedulersample/com.hjm.jobschedulersample.CameraJobService}
```


## CameraJobService の実装
サンプルアプリのメインとなるクラスは CameraJobService クラスです。
https://github.com/hshiozawa/JobSchedulerSampleForContentURI/blob/master/app/src/main/java/com/hjm/jobschedulersample/CameraJobService.java


このクラスの実装のポイントについて説明します。

### コールバック

CameraJobService が実行された場合に呼び出されるコールバックは次の通りです。

```java
public class CameraJobService extends JobService {
    /*
     * ジョブが実行される時に呼び出されるメソッド
     */
    @Override
    public boolean onStartJob(final JobParameters params) {
        Log.i(TAG, "onStartJob: " + params);
        ... (省略) ...
        return true; // 返り値に応じてジョブのステータスが変わる
    }

    /*
     * ジョブがキャンセルされる時に呼び出されるメソッド
     */
    @Override
    public boolean onStopJob(JobParameters params) {
        Log.i(TAG, "onStopJob: " + params);
        ... (省略) ...
        return false; // 返り値に応じてジョブはリトライされる
    }   
}
```

- CameraJobService は android.app.job.JobService を拡張しています。
- boolean onStartJob() はジョブが実行された時に呼び出されます。
  - このメソッドで true を返すと、このジョブはまだ処理中であることを OS に示します。
     - true を返した状態で何もしないとジョブが終了せずに残ってしまいます。適切な箇所で finishedJob() を呼ぶ必要があります（後述）
  - このメソッドで false を返すと、このジョブはもう処理が終わっていることを OS に示します。
     - OS はジョブシステムからこのジョブを取り除きます。
- boolean onStopJob() はジョブがキャンセルされた時に呼び出されます。
  - このメソッドで ture を返すとジョブはリトライされます。
     - リトライの方式については android.app.job.JobInfo.Builder#setBackoffCriteria() で設定された方式が利用されます。
  - このメソッドで false を返すとジョブはそのまま終了します。

### ジョブの登録
ジョブを OS に登録するためのメソッドは CameraJobService.startJob() として実装しています。

```java
public static void startJob(Context context) {
        Log.i(TAG, "startJob");

        // スケジューラーの取得
        JobScheduler scheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);

        // ビルダーの作成
        JobInfo.Builder builder = new JobInfo.Builder(
                CONTENT_URI_JOB_ID, // 今回は一つだけジョブが実行されていればよいので固定の ID を使う
                new ComponentName(context, CameraJobService.class));

        // Content URI の監視
        builder.addTriggerContentUri(
                new JobInfo.TriggerContentUri(
                        MediaStore.Images.Media.EXTERNAL_CONTENT_URI, // 外部メディアストレージの監視
                        JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS));

        // ジョブの投入
        scheduler.schedule(builder.build());
    }
```

- JobInfo.Builder の第一引数はジョブを識別するための ID です。
  - この ID はアプリプロセスの実行ユーザーID（uid）ごとに一意である必要があります。
  - あるジョブがすでに実行されている場合、新たに同じ ID のジョブを登録すると、前の実行中のジョブはキャンセルされます。
      - その際、onStopJob() が呼ばれます。
- カメラを監視するジョブは常に一つだけあれば問題ありません。
      - 今回の実装では定数化しています。

### ジョブの処理
実際にジョブが実行された時の処理です。

```java
@Override
public boolean onStartJob(final JobParameters params) {
    Log.i(TAG, "onStartJob: " + params);

    // バックグラウンドで実行
    new AsyncTask<Void, Void, Void>() {
        @Override
        protected Void doInBackground(Void... parameters) {
            doProcess(params); // 実際の処理を実行
            return null;
        }

        @Override
        protected void onPostExecute(Void result) {
            // このジョブを終了させる
            jobFinished(params, false);

            // もう一度監視ジョブを作成
            startJob(getApplicationContext());
        }
    }.execute();

    // ジョブは別スレッドで実行中。終了させないようにするために true を返す
    return true;
}
```

- onStartJob() はメインスレッドで呼び出されます。
    - そのため、処理自体はバックグラウンドスレッドで行うべきです。
    - 今回の実装では大した処理を行っていませんが、一応バックグラウンドスレッドを利用しています。
- バックグラウンドで処理を行うのですぐにジョブを完了化していません。
    - そのため、返り値は true を返します
    - バックグラウンドでの処理が終わったら onPostExecute() で jobFinished() を呼び出して、ジョブを終了させます。
- 一度でもカメラの撮影を検知してジョブが実行されるとそのジョブは使えなくなります。
    - 常にカメラの撮影を検知したければジョブを終了したあと、新たなジョブを登録する必要があります。
    - jobFinishled() の第二引数を true にすることで利用できるリスケジューリング機能は利用できません。
      - 同じ content uri リストを持っているジョブが何度も実行されてしまいます。

# ジョブの状況の確認
次のコマンドで OS 全体の JobScheduler の状況を確認できます。

```sh
$> adb shell dumpsys jobscheduler
```

サンプルアプリを動作させていると、`Registered XX jobs:` の箇所でサンプルアプリのジョブを確認できます。

```
Started users: [0]
Registered 61 jobs:
... (省略) ...
JOB #u0a272/1000: 490545d com.hjm.jobschedulersample/.ContentUriJobService
    u0a272 tag=*job*/com.hjm.jobschedulersample/.ContentUriJobService
    Source: uid=u0a272 user=0 pkg=com.hjm.jobschedulersample
    JobInfo:
      Service: com.hjm.jobschedulersample/.ContentUriJobService
      Requires: charging=false deviceIdle=false
      Trigger content URIs:
        1 content://media/external/images/media
      Backoff: policy=1 initial=+30s0ms
    Required constraints: CONTENT_TRIGGER
    Satisfied constraints: APP_NOT_IDLE DEVICE_NOT_DOZING
    Unsatisfied constraints: CONTENT_TRIGGER
    Earliest run time: none
    Latest run time: none
    Ready: false (job=false pending=false active=false user=true)
```

（補足)
onStartJob() で true を返したままにして finishedJob() を呼ばない場合、`Active jobs:` の項目に実行中のままのジョブが表示されます。こうなっているのはおかしいので、ちゃんと finishedJob() を呼びましょう。

```
Active jobs:
  Slot #0: aea9246 #u0a272/1000 com.hjm.jobschedulersample/.CameraJobService
    Running for: +2s657ms, timeout at: +9m57s394ms
    u0a272 tag=*job*/com.hjm.jobschedulersample/.CameraJobService
    Source: uid=u0a272 user=0 pkg=com.hjm.jobschedulersample
    Required constraints: CONTENT_TRIGGER
    Earliest run time: none
    Latest run time: none
  Slot #1: inactive
  Slot #2: inactive
  Slot #3: inactive
  Slot #4: inactive
  Slot #5: inactive
  Slot #6: inactive
  Slot #7: inactive
  Slot #8: inactive
  Slot #9: inactive
  Slot #10: inactive
  Slot #11: inactive
  Slot #12: inactive
  Slot #13: inactive
  Slot #14: inactive
  Slot #15: inactive
```

# 感想とまとめ

- ジョブは一度実行されたらそれで監視が終わってしまいます
  - BroadCastReceiver と違って毎回ジョブを登録しなおす必要があります。
- カメラの撮影後にジョブが実行されるまでかなりラグがあります
  - バッテリー利用の最適化が目的のジョブシステムなので、即応性はかなり低いです。
- ジョブはアプリプロセスが kill されても登録され続けています。
- 撮影の監視においてジョブのリスケジューリングは使えません。
  - 同じ content URI リストを保持したジョブが何度も実行されてしまいます。


# 参考
JobScheduler の実装に関しては次のサイトを参考にしました。
http://blog.techium.jp/entry/2016/03/09/003314