AccessibilityServiceを使わずにAndroidのアプリブロッカーを作る
僕はHonorFitというアプリブロッカーを作っています。腕立て伏せをするとスクリーンタイムを稼げるアプリです(自己申告制で、カメラは使いません)。最初のAndroid版は、Androidのブロッカーの多くと同じやり方、つまりAccessibilityServiceでアプリをブロックしていました。8月にそれを丸ごと外して、UsageStatsManager、フォアグラウンドサービス、オーバーレイウィンドウに置き換えました。ブロックは少しゆるくなりましたが、オンボーディングはずっと怖くなくなり、Playの審査もずっとシンプルになりました。
この記事では、そのアーキテクチャの全体像と、途中でハマったことを紹介します。コードはすべてローカルのExpoモジュール内のKotlinですが、React Native固有の話はひとつもありません。
Accessibility APIをやめた理由
ユーザー補助サービス(AccessibilityService)は、Androidで使えるいちばん強力な道具です。ウィンドウが切り替わった瞬間にそれがわかり、performGlobalAction(GLOBAL_ACTION_HOME)を呼べば、ブロック中のアプリからその場でユーザーを追い出せます。多くのブロッカーが使っているのはそのためです。代償はこうです。
- 許可を求める画面がとにかく怖い。Androidはユーザーに、このアプリは画面上のすべてを見て、あなたの代わりに操作できます、と伝えます。「データはスマホの外に出ません」を売りにしているアプリが初日にそれを求めるのは印象が悪いですし、そこでやめてしまう人もたくさんいます。
- Google Playはこれを機密性の高いAPIとして扱う。ユーザー補助ツールでないなら、アプリ内での目立つ開示、Play Consoleでの申告、そしてその用途でOKだと判断してくれる審査担当者が必要です。開示用のモーダルはもう作ってありましたが、できれば必要ないほうがいい。
- ブロッカーに必要な範囲をはるかに超えて見えてしまう。ブロッカーが知る必要があるのは、どのアプリがフォアグラウンドにあるか、ただそれだけです。
そのひとつの事実は、別の場所からも手に入ります。Digital Wellbeingの裏で使われている使用状況イベントです。
全体の構成
権限は3つで、どれもユーザーが設定画面で許可します。
| 権限 | 用途 |
|---|---|
PACKAGE_USAGE_STATS(「使用状況へのアクセス」) | UsageEventsを読んで、どのアプリが前面にあるかを知るため。実行時には付与されないので、ユーザーを設定画面に案内して、app-opを確認します。 |
SYSTEM_ALERT_WINDOW(「他のアプリの上に重ねて表示」) | ブロック中のアプリの上にロック画面を描くため。 |
FOREGROUND_SERVICE_SPECIAL_USE | 監視サービス用。アプリのブロックは、用意されているフォアグラウンドサービスタイプのどれにも当てはまらないので、specialUseにして、理由を説明するサブタイプ文字列を付けます。 |
構成要素はこうです。
ForegroundMonitorServiceは、強制すべきものがあるとき(ブロックリストが空でなく、使用状況へのアクセスが許可されているとき)だけ動きます。1秒に1回、それも画面がオンのときだけ、UsageStatsManagerに何がフォアグラウンドに来たかを問い合わせます。時間が残っているブロック対象アプリなら貯金された残高を減らし、0になったらオーバーレイを表示します。- 必須の通知が、残り時間の表示を兼ねています(「残り12分」)。Androidに押しつけられるものに、ちゃんと仕事をさせているわけです。
- 状態はすべて
SharedPreferencesに置きます。JSには置きません。サービスは、React Nativeのランタイムが死んでいるとき(ほとんどの時間がそうです)でも動き続けなければならないからです。JS側はモジュール経由で時間を預け入れて残高を読むだけで、強制はサービスが担当します。
サービスのマニフェストエントリです。
<service
android:name=".ForegroundMonitorService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="User-configured app blocking for digital wellbeing: monitors
which app is in the foreground (UsageStats) so apps the user chose to block
can be locked when their earned screen time runs out." />
</service>
Play Consoleのフォアグラウンドサービスの申告でも、specialUseの理由を説明します。両方で同じ説明をしておくと話が通りやすくなります。
UsageStatsのポーリング(と書き込みの遅延)
中心となるループは小さいです。前回のポーリング以降のイベントをスキャンして、最後のMOVE_TO_FOREGROUNDのパッケージを覚えておきます(ACTIVITY_RESUMEDと同じ定数で、API 29で名前が変わりました)。
val end = System.currentTimeMillis()
val events = usm.queryEvents(windowStart, end)
val event = UsageEvents.Event()
while (events.hasNextEvent()) {
events.getNextEvent(event)
if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND) {
currentForeground = event.packageName
}
}
// Re-scan the last 15 s next time: see below.
windowStart = end - REQUERY_OVERLAP_MS
handleForeground(currentForeground)
アクセシビリティイベントと比べて、うれしい副作用もあります。キーボードや通知シェードはアクティビティではないので、「ユーザーがアプリを離れた」という偽の離脱として現れることがありません。
ハマりどころ:イベントは遅れて届く
最初のバージョンでは、ウィンドウを厳密に前へ進めていました。毎回のポーリングで[lastEnd, now]を問い合わせるやり方です。だいたいは動いたのですが、コールドスタートしたスマホで、最初にブロック対象アプリを開いたことがまったく検知されませんでした。UsageStatsはイベントを遅れてストアに書き込むので、10:00:00.900のタイムスタンプが付いたイベントが、すでに10:00:01までカバーしたポーリングのあとで現れることがあります。厳密に進むウィンドウだと、そのイベントは永遠にスキップされます。
対策:毎回のポーリングで、直前の一定時間(僕は15秒にしています)を重ねて再スキャンします。同じイベントを2回見ても問題ありません。どちらにしても、ウィンドウ内で最後のresumeが勝つからです。15秒より遅れて届いたイベントはやはり失われますが、まだ一度も見たことはありません。
ハマりどころ:最初のポーリングは長くさかのぼる
サービスが起動したとき(再起動後や、画面がオンになったとき)には、「最後にわかっているアプリ」がありません。ユーザーがTikTokに20分いて、最初のポーリングが1分しかさかのぼらないと、アプリを切り替えるまで何も見えません。なので、最初のポーリングでは直近1時間をスキャンします。ランチャーもアクティビティなので、これで問題ありません。最後にresumeされたものが、本当に前面にあるものです。
ハマりどころ:アクセスを取り消されても、ただの沈黙に見える
サービスの実行中にユーザーが使用状況へのアクセスをオフにしても、queryEventsは例外を投げません。何も返さなくなるだけです。最後の読み取り結果(「Instagramが前面にある」)が古いまま残り、残高がいつまでも減り続けます。なので、毎回のポーリングでまずapp-opを確認して、なくなっていたら時計を止めます。
fun hasUsageAccess(context: Context): Boolean {
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
val mode = appOps.checkOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
return mode == AppOpsManager.MODE_ALLOWED
}
画面オフなら停止
画面がオフになると、サービスは進行中のセッションを精算して、ポーリングを完全に止めます。画面オフでアプリを使っている人はいませんし、スマホを見ているあいだだけ動く1 Hzのループなら負荷はわずかです。画面が再びオンになったあとの最初のポーリングでは、日付の切り替え(深夜0時のリセット、デイリーベース)も処理します。なので、HonorFitを一度も開かなくても、ユーザーがブロック対象アプリにたどり着く前に済んでいます。
ロック用オーバーレイ
アクセシビリティがなければGLOBAL_ACTION_HOMEもないので、ユーザーをアプリから追い出すことはできません。代わりに覆い隠します。「時間を稼ぐ」ボタンと「ホームに戻る」ボタンを持った、TYPE_APPLICATION_OVERLAYウィンドウです。
- ディスプレイ全体を覆う。デフォルトでは、オーバーレイはシステムバーの分だけ内側に置かれ、ブロック中のアプリが端からのぞいてしまいます。
FLAG_LAYOUT_NO_LIMITSで直ります。FLAG_LAYOUT_IN_SCREENはAPI 30で非推奨になったので、R未満でだけ追加します。 - 「ホームに戻る」は、本当にホームに戻らないといけない。オーバーレイを隠すだけだと、ブロック中のアプリがまた見えて、次のポーリングでまたロックがかかります。代わりにランチャーを起動します。
startActivity(Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - 起動してから隠す。「時間を稼ぐ」ボタンはHonorFitを開きますが、これはバックグラウンドからのアクティビティ起動です。アプリに表示中のオーバーレイウィンドウがあるので、Androidはこれを許可します。なので、オーバーレイを表示したままアクティビティを起動し、そのあとでオーバーレイを外します。逆の順番にすると、起動が黙ってブロックされます。
- オーバーレイの権限がなくても、カウントは続ける。「他のアプリの上に重ねて表示」が許可されていなくても、サービスは正直に残高を減らし続けます。権限が戻るまでロックを描けない、というだけです。
サービスを生かし続ける
ここがいちばん時間のかかるところで、しかもほとんど自分のコードの問題ではありません。
START_STICKYだけでは足りない
Samsungの「スリープ中のアプリ」、MIUI、OnePlusなどは、バックグラウンドサービスを止めたうえに、stickyな再起動まで握りつぶします。ブロッカーにとってはこれが最悪のケースです。時間が残っているあいだはみんなアプリを開かないので、モニターが消えたことに誰も気づきません。HonorFitは、15分ごとにサービスを立て直す、inexactでnon-wakeupな繰り返しアラームを設定しています。スマホを起こすことはなく、画面オフのあいだに延期されたアラームは、画面がオンになってすぐに発火します。モニターが必要になるのは、まさにそのタイミングです。
Android 12以降では、起動させてもらえないことがある
バックグラウンド(アラームやstickyな再起動)からフォアグラウンドサービスを起動すると、例外扱いの条件を満たしていない限りForegroundServiceStartNotAllowedExceptionが投げられます。ここで関係するのは2つ。ユーザーがアプリのバッテリー最適化をオフにしている場合と、アプリがSYSTEM_ALERT_WINDOWを持っている場合です(後者が効くのはAndroid 15より前だけです)。なので、こうしています。
- 例外をキャッチして、静かに抜けます。プロセスをクラッシュさせても誰も得をしませんし、次にアプリを開いたときにどのみちサービスは起動します。
- ユーザーに、電池使用量を「制限なし」にしてもらいます。HonorFitの設定には「バックグラウンドの安定性」カードがあって、正しい画面まで案内します。スマホによっては、この設定が動くか動かないかの分かれ目です。
再起動とアップデート
BOOT_COMPLETEDとMY_PACKAGE_REPLACEDのレシーバーで監視を再開します。後者は忘れがちです。Playの自動アップデートはプロセスを終了させたうえ、サービスを再起動してくれません。なので、これがないとアップデートのたびに、ユーザーがアプリを開くまでブロッカーが黙ってオフになります。どちらのブロードキャストもバックグラウンド起動の制限から除外されていて、exported="false"のレシーバーでも受け取れます。
細かいこと
- 絶対にブロックできないようにすべきアプリがある:設定、時計(アラーム!)、Personal Safety、それにSamsungの時計パッケージのようなメーカー独自版です。ピッカーだけでなく、ネイティブコードでもブロックリストから除外します。
- サービスはJSなしで動くので、
t()を呼べない。アプリがオーバーレイと通知の文字列を現在の言語でSharedPreferencesに送り込み、それが届くまでサービスは英語にフォールバックします。 - サービスの再起動をまたいで開いたままのセッションは、サービスが死んでいたあいだ残高を減らせていない。起動時に経過した時間を(上限をかけて)精算し、新しいセッションを始めます。そうすれば、サービスがいなくて見ていなかった時間の分を、誰かが差し引かれることはありません。
- Android 14以降では、フォアグラウンドサービスの通知をスワイプで消せる。サービスは通知を更新し続ける(毎秒出し直さないように重複は排除しています)ので、通知はまた戻ってきます。
トレードオフ
このやり方はアクセシビリティよりゆるいので、そこは正直に書いておきます。
- オーバーレイがかかるまで、ブロック中のアプリが最大1ポーリング間隔(約1秒)見えてしまう。
- ブロック中のアプリはオーバーレイの下で動き続ける。動画を再生していたら、「ホームに戻る」をタップするまで音は聞こえたまま。
- メーカーがサービスを放っておいてくれることが前提。だからバッテリーの設定がとても重要。
自己申告制のブロッカーにとっては、これが正しいトレードオフです。HonorFitは絶対に破られないことを目指していません。本気でズルをしたい人は、やっていない回数を記録すればいいだけです。目的は、ひと呼吸おくことと対価を払うことで、牢屋に閉じ込めることではありません。その代わりに、「フルコントロール」ではなく「使用状況へのアクセス」と書かれた権限画面、ずっとシンプルなPlayの審査、そして前面にあるアプリのパッケージ名というたったひとつのことだけを読むサービスが手に入ります。
iOSでも作っているなら、向こうはまったく別の世界です。AppleのScreen Timeのドキュメントに書かれていないことをまとめています。
HonorFit Blockerは、iPhoneとAndroid向けの無料アプリブロッカーです。腕立て伏せやスクワット、好きなエクササイズでスクリーンタイムを稼げます。自己申告制で、カメラもアカウントも不要、データはスマホの外に出ません。