0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AndroidのNotificationListenerServiceで通知をログに残す

0
Posted at

はじめに

Androidには NotificationListenerService という特殊なサービスが用意されており、端末に届くすべての通知をアプリ側でリアルタイムに受け取ることができます。通知のロギング、自動応答、リマインダー連携など、様々なユースケースに活用できます。

この記事では、私が開発したOSSアプリ NotiTrace(通知ログアプリ)での実装をベースに、NotificationListenerService の基本から実践的な実装パターンまでを解説します。

NotiTrace は、端末に届く全通知をローカルに安全記録するプライバシーファーストの通知ログアプリです。INTERNET パーミッションすら持たず、すべての通知データを暗号化SQLiteに保存します。


モチベーション

デバッグ機能を入れていない商用アプリでPushの情報が正しく取得できているか確認したいと思ったことはありませんか?
Android標準の通知の履歴では受信時やメッセージがわかるけど受け取った生データが見られないです。また、世の中のNotificationListenerServiceを活用したアプリはPlayStoreに多くありますが、二段階認証のメッセージなど、他のアプリに監視されたくありません。
ネットワークも使わない安全に記録するアプリを生成AIを使ってサクッと作ろう、と作り始めた次第です。

事前準備

AndroidManifest.xmlへの登録

NotificationListenerService を使うには、まずManifestにサービスを宣言する必要があります。

<!-- 通知リスナーサービス -->
<service
    android:name=".service.NotiTraceListenerService"
    android:exported="true"
    android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
    <intent-filter>
        <action android:name="android.service.notification.NotificationListenerService"/>
    </intent-filter>
</service>

ポイント:

  • android:exported="true" は必須です。Android OSがこのサービスをバインドするため、外部から到達できる必要があります
  • android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" を指定することで、OSのみがバインドできるよう制限します。これを省略するとサードパーティアプリが自由にバインドできてしまいます

ユーザーへの権限付与

NotificationListenerService はシステムレベルの特殊権限であり、通常の <uses-permission> では取得できません。ユーザーが設定画面から手動で許可する必要があります。

// 許可状態の確認
fun isNotificationListenerEnabled(context: Context): Boolean {
    val packageName = context.packageName
    val enabledListeners = NotificationManagerCompat.getEnabledListenerPackages(context)
    return packageName in enabledListeners
}

// 設定画面へ誘導
fun openNotificationListenerSettings(context: Context) {
    val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
    context.startActivity(intent)
}

基本実装

最小構成のサービス

class MyNotificationListenerService : NotificationListenerService() {

    override fun onNotificationPosted(sbn: StatusBarNotification?) {
        // 通知が届いたときに呼ばれる
    }

    override fun onNotificationRemoved(sbn: StatusBarNotification?) {
        // 通知が削除されたときに呼ばれる
    }
}

StatusBarNotification(SBN)には通知の全情報が含まれています。

StatusBarNotificationから情報を取り出す

override fun onNotificationPosted(sbn: StatusBarNotification?) {
    sbn ?: return

    val packageName = sbn.packageName    // 通知を送ったアプリのパッケージ名
    val postTime = sbn.postTime          // 通知が投稿されたUnix時刻(ms)
    val isOngoing = sbn.isOngoing        // スワイプで消せない通知かどうか

    // 通知本文の取得
    val extras = sbn.notification.extras
    val title   = extras.getString(Notification.EXTRA_TITLE)
    val text    = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()
    val bigText = extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString()
    val subText = extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.toString()
    val ticker  = sbn.notification.tickerText?.toString()
}

extrasには他にも様々なキーが入っています。EXTRA_TITLE, EXTRA_TEXT が基本ですが、メッセージアプリなどは EXTRA_BIG_TEXT に長文テキストを入れることが多いです。


NotiTraceでの実践的実装

Hilt + Coroutinesで非同期保存

onNotificationPosted() はメインスレッドで呼ばれます。DBへの書き込みなどの重い処理をここで行うとANRの原因になるため、必ず非同期で処理します。

NotiTraceでは Hilt(DI) と Coroutines を組み合わせてシンプルに実装しています

@AndroidEntryPoint
class NotiTraceListenerService : NotificationListenerService() {

    @Inject
    lateinit var repository: NotificationRepository

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    override fun onNotificationPosted(sbn: StatusBarNotification?) {
        sbn ?: return

        // 自アプリの通知は記録しない(無限ループ防止)
        if (sbn.packageName == applicationContext.packageName) return

        scope.launch {
            try {
                val entity = NotificationExtractor.extract(sbn)
                repository.save(entity)
            } catch (e: Exception) {
                // サービスクラッシュを防ぐ。ログは端末の logcat に残る。
                android.util.Log.e("NotiTraceListener", "Failed to save notification", e)
            }
        }
    }

    override fun onNotificationRemoved(sbn: StatusBarNotification?) {
        // 通知の削除イベントは記録しない(ログは残す方針)
    }

    override fun onDestroy() {
        super.onDestroy()
        scope.cancel()
    }
}

各設計判断の解説:

ポイント 理由
@AndroidEntryPoint Hilt の DI を NotificationListenerService で使うために必要
SupervisorJob() 子コルーチンの失敗が他のコルーチンに伝播しない。一つの通知の保存失敗が次の通知処理を妨げないようにする
Dispatchers.IO DB書き込みをIOスレッドで実行し、メインスレッドをブロックしない
自アプリパッケージのフィルタ アプリ自身が通知を出すと onNotificationPosted → 通知保存 → 通知を出す…という無限ループが起きる
try-catch 予期しない例外でサービスがクラッシュするのを防ぐ
onDestroy での scope.cancel() サービス終了時に未完了のコルーチンをリークさせない

通知の受信→保存フロー

Android OS
    ↓ onNotificationPosted(sbn)
NotiTraceListenerService
    ↓ scope.launch (IO Dispatcher)
NotificationExtractor.extract(sbn)
    ├─ タイトル・テキスト抽出
    ├─ 通知タイプ分類
    ├─ シグネチャ(SHA-256)生成
    └─ 生JSON生成
    ↓
NotificationRepository.save(entity)
    └─ Room DB(SQLCipher暗号化)に保存

通知タイプの分類ロジック

Androidの通知には様々な種類があります。NotiTraceでは StatusBarNotificationflagspriority、そして extras のキーからヒューリスティクスで7種類に分類しています。
現時点では残念ながら正確に分類はできていません。

7種類の通知タイプ

enum class NotificationType(
    val code: String,
    val label: String,
    val description: String,
) {
    FOREGROUND_SERVICE("foreground_service", "常駐",      "フォアグラウンドサービスの常駐通知"),
    ONGOING(           "ongoing",            "進行中",     "スワイプで消せない進行中の通知"),
    GROUP_SUMMARY(     "group_summary",       "グループ",   "通知グループのサマリー"),
    REMOTE_PUSH(       "remote_push",         "リモート",   "サーバーからのプッシュ通知"),
    REMOTE_SILENT(     "remote_silent",       "リモート静音", "サーバーからの静音プッシュ通知"),
    LOCAL(             "local",              "ローカル",    "アプリが生成した通知"),
    LOCAL_SILENT(      "local_silent",        "サイレント",  "アプリが生成した静音通知"),
    ;

    companion object {
        private val codeMap = entries.associateBy { it.code }
        fun fromCode(code: String): NotificationType = codeMap[code] ?: LOCAL
    }
}

分類ロジック

fun classifyNotification(sbn: StatusBarNotification): NotificationType {
    val flags = sbn.notification.flags

    // 1. フォアグラウンドサービス(音楽再生、ナビなど)
    if (flags and Notification.FLAG_FOREGROUND_SERVICE != 0) {
        return NotificationType.FOREGROUND_SERVICE
    }

    // 2. 進行中の通知(ダウンロード、通話中など)
    if (flags and Notification.FLAG_ONGOING_EVENT != 0) {
        return NotificationType.ONGOING
    }

    // 3. グループサマリー(複数通知をまとめた親通知)
    if (flags and Notification.FLAG_GROUP_SUMMARY != 0) {
        return NotificationType.GROUP_SUMMARY
    }

    val isRemote = hasRemotePushMarkers(sbn)
    val isSilent = isSilentPriority(sbn)

    // 4/5. リモートプッシュ(FCM経由)
    if (isRemote) {
        return if (isSilent) NotificationType.REMOTE_SILENT
        else NotificationType.REMOTE_PUSH
    }

    // 6. ローカル静音
    if (isSilent) {
        return NotificationType.LOCAL_SILENT
    }

    // 7. 通常のローカル通知(デフォルト)
    return NotificationType.LOCAL
}

FCMプッシュの検出

FCM(Firebase Cloud Messaging)経由の通知は、extras に特定のキーが含まれているという特徴を利用して検出できます

private val REMOTE_PUSH_MARKER_KEYS = setOf(
    "google.message_id",
    "google.sent_time",
    "google.delivered_priority",
    "google.original_priority",
    "google.c.a.e",          // FCM Analytics
    "google.c.sender.id",
    "gcm.n.e",               // GCM notification key
    "com.google.firebase.messaging.default_notification_channel_id",
)

fun hasRemotePushMarkers(sbn: StatusBarNotification): Boolean {
    // FLAG_LOCAL_ONLY が立っていればリモートとみなさない
    if (sbn.notification.flags and Notification.FLAG_LOCAL_ONLY != 0) return false

    val extras = sbn.notification.extras
    for (key in extras.keySet()) {
        if (key in REMOTE_PUSH_MARKER_KEYS) return true
    }
    return false
}

サイレント通知の判定は priority で行います:

fun isSilentPriority(sbn: StatusBarNotification): Boolean {
    @Suppress("DEPRECATION")
    val priority = sbn.notification.priority
    return priority <= Notification.PRIORITY_LOW  // -1以下
}

Note: priority はAPI 26以降で非推奨になりましたが、NotificationListenerService での受信時は現在でも値が入っています。Channel の importance とは別物なので注意が必要です。


ハマりポイントと注意事項

1. 自アプリの通知を必ず除外する

最重要ポイントです。自アプリが通知を出すと無限ループが発生します。

// これを忘れると:通知を受信→DBに保存→保存完了通知を出す→また受信→…
if (sbn.packageName == applicationContext.packageName) return

2. onNotificationPostedはメインスレッド

onNotificationPosted() はAndroid OSのメインスレッドから呼ばれます。DB書き込み・ファイルI/Oなどは必ずバックグラウンドスレッドで行いましょう。

// NG: メインスレッドでブロッキング処理
override fun onNotificationPosted(sbn: StatusBarNotification?) {
    database.save(sbn)  // ANRのリスク
}

// OK: Coroutinesで非同期化
override fun onNotificationPosted(sbn: StatusBarNotification?) {
    scope.launch(Dispatchers.IO) {
        database.save(sbn)
    }
}

3. サービスがいつの間にか停止する

Androidの省電力機能(Doze、バッテリー最適化)によってサービスが強制停止されることがあります。onListenerConnected()onListenerDisconnected() でこの状態を検知できます

override fun onListenerConnected() {
    super.onListenerConnected()
    // サービスがOSとの接続を確立した(=通知を受け取れる状態)
}

override fun onListenerDisconnected() {
    super.onListenerDisconnected()
    // 切断された。requestRebind() で再接続をリクエストできる
    requestRebind(ComponentName(this, NotiTraceListenerService::class.java))
}

4. Hilt + NotificationListenerServiceの組み合わせ

@AndroidEntryPoint アノテーションを付けることで、通常の Service と同様にHiltのDIが使えます。

@AndroidEntryPoint  // ← これが必要
class NotiTraceListenerService : NotificationListenerService() {
    @Inject
    lateinit var repository: NotificationRepository
    // ...
}

5. extras取得時の例外処理

extras.get(key) は稀に例外を投げることがあります(システムアプリが特殊なParcelableを埋め込む場合など)。全extraを取得してJSONにシリアライズする場合は try-catch が必須です

for (key in extras.keySet()) {
    val value = try {
        extras.get(key)?.toString() ?: "null"
    } catch (_: Exception) {
        "<unreadable>"
    }
    put(key, value)
}

まとめ

NotificationListenerService の実装ポイントを整理します

基本フロー

  1. AndroidManifest.xml にサービスを宣言(exported="true" 必須)
  2. ユーザーに設定画面で権限を許可してもらう
  3. onNotificationPosted() でSBNを受け取り、バックグラウンドで処理

今回紹介したNotiTraceのフルソースコードはGitHubで公開しています。通知分類ロジック・暗号化DB・JSONL エクスポートなど、実際のアプリに必要な実装が揃っていますので、ぜひ参考にしてください。


参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?