AccessibilityServiceを使わずにAndroidのアプリブロッカーを作る

UsageStatsManager、フォアグラウンドサービス、オーバーレイ。リリース済みのアプリから。Thomas Willheim · 2026年11月9日

僕はHonorFitというアプリブロッカーを作っています。腕立て伏せをするとスクリーンタイムを稼げるアプリです(自己申告制で、カメラは使いません)。最初のAndroid版は、Androidのブロッカーの多くと同じやり方、つまりAccessibilityServiceでアプリをブロックしていました。8月にそれを丸ごと外して、UsageStatsManager、フォアグラウンドサービス、オーバーレイウィンドウに置き換えました。ブロックは少しゆるくなりましたが、オンボーディングはずっと怖くなくなり、Playの審査もずっとシンプルになりました。

この記事では、そのアーキテクチャの全体像と、途中でハマったことを紹介します。コードはすべてローカルのExpoモジュール内のKotlinですが、React Native固有の話はひとつもありません。

Accessibility APIをやめた理由

ユーザー補助サービス(AccessibilityService)は、Androidで使えるいちばん強力な道具です。ウィンドウが切り替わった瞬間にそれがわかり、performGlobalAction(GLOBAL_ACTION_HOME)を呼べば、ブロック中のアプリからその場でユーザーを追い出せます。多くのブロッカーが使っているのはそのためです。代償はこうです。

そのひとつの事実は、別の場所からも手に入ります。Digital Wellbeingの裏で使われている使用状況イベントです。

全体の構成

権限は3つで、どれもユーザーが設定画面で許可します。

権限用途
PACKAGE_USAGE_STATS(「使用状況へのアクセス」)UsageEventsを読んで、どのアプリが前面にあるかを知るため。実行時には付与されないので、ユーザーを設定画面に案内して、app-opを確認します。
SYSTEM_ALERT_WINDOW(「他のアプリの上に重ねて表示」)ブロック中のアプリの上にロック画面を描くため。
FOREGROUND_SERVICE_SPECIAL_USE監視サービス用。アプリのブロックは、用意されているフォアグラウンドサービスタイプのどれにも当てはまらないので、specialUseにして、理由を説明するサブタイプ文字列を付けます。

構成要素はこうです。

サービスのマニフェストエントリです。

<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ウィンドウです。

サービスを生かし続ける

ここがいちばん時間のかかるところで、しかもほとんど自分のコードの問題ではありません。

START_STICKYだけでは足りない

Samsungの「スリープ中のアプリ」、MIUI、OnePlusなどは、バックグラウンドサービスを止めたうえに、stickyな再起動まで握りつぶします。ブロッカーにとってはこれが最悪のケースです。時間が残っているあいだはみんなアプリを開かないので、モニターが消えたことに誰も気づきません。HonorFitは、15分ごとにサービスを立て直す、inexactでnon-wakeupな繰り返しアラームを設定しています。スマホを起こすことはなく、画面オフのあいだに延期されたアラームは、画面がオンになってすぐに発火します。モニターが必要になるのは、まさにそのタイミングです。

Android 12以降では、起動させてもらえないことがある

バックグラウンド(アラームやstickyな再起動)からフォアグラウンドサービスを起動すると、例外扱いの条件を満たしていない限りForegroundServiceStartNotAllowedExceptionが投げられます。ここで関係するのは2つ。ユーザーがアプリのバッテリー最適化をオフにしている場合と、アプリがSYSTEM_ALERT_WINDOWを持っている場合です(後者が効くのはAndroid 15より前だけです)。なので、こうしています。

再起動とアップデート

BOOT_COMPLETEDとMY_PACKAGE_REPLACEDのレシーバーで監視を再開します。後者は忘れがちです。Playの自動アップデートはプロセスを終了させたうえ、サービスを再起動してくれません。なので、これがないとアップデートのたびに、ユーザーがアプリを開くまでブロッカーが黙ってオフになります。どちらのブロードキャストもバックグラウンド起動の制限から除外されていて、exported="false"のレシーバーでも受け取れます。

細かいこと

トレードオフ

このやり方はアクセシビリティよりゆるいので、そこは正直に書いておきます。

自己申告制のブロッカーにとっては、これが正しいトレードオフです。HonorFitは絶対に破られないことを目指していません。本気でズルをしたい人は、やっていない回数を記録すればいいだけです。目的は、ひと呼吸おくことと対価を払うことで、牢屋に閉じ込めることではありません。その代わりに、「フルコントロール」ではなく「使用状況へのアクセス」と書かれた権限画面、ずっとシンプルなPlayの審査、そして前面にあるアプリのパッケージ名というたったひとつのことだけを読むサービスが手に入ります。

iOSでも作っているなら、向こうはまったく別の世界です。AppleのScreen Timeのドキュメントに書かれていないことをまとめています。

HonorFit Blockerは、iPhoneとAndroid向けの無料アプリブロッカーです。腕立て伏せやスクワット、好きなエクササイズでスクリーンタイムを稼げます。自己申告制で、カメラもアカウントも不要、データはスマホの外に出ません。

あわせて読みたい