🎹

何も再生しない常駐通知を specialUse で Play 審査に通した

に公開

はじめに

私は普段からピアノで遊んでいるんですが、
ふと聴いていた曲をピアノで弾きたくなることがあります。

その発作のたびに曲名をブラウザに入力して、コードを検索するのが非常に億劫でした。
Spotify からコピペはできないし、曲名だけだと同名の別の人の曲が出てきたりして辛かったです。

だったら自分で通知を出せばいい、ということで、再生中の曲名を自前の常駐通知に出して、そこの検索ボタンを押せばすぐにブラウザに遷移する自作アプリを作ってみました。

常駐通知の「検索」を押すとブラウザの検索結果に遷移する
再生中の曲が常駐通知に出て、「検索」を押すとブラウザが開く(YouTube 再生時。Spotify でも同じ)

作ったのはいいんですが、ここで問題が出ました。
常駐通知を出すにはフォアグラウンドサービス(FGS)が必要で、Android 14 以降の FGS は「何のためのサービスか」を type として宣言しないといけません。
音楽系のアプリなら普通は mediaPlayback ですが、このアプリは自分では何も再生しません。
YouTube や Spotify の MediaSession を眺めているだけです。
なので mediaPlayback は使えず、specialUse という「その他」枠を名乗って、Play Console に用途を申告する必要がありました。

この記事は、specialUse を選んだ理由・Manifest の書き方・実際に一発で通った申告文の記録です。
対象読者は、他アプリを監視して常駐するタイプのアプリを Android 14 以降向けに作ろうとしている人です。

なぜ mediaPlayback ではなく specialUse なのか

targetSdk 34(Android 14)以上では、FGS に type の宣言が必須です。
宣言しないと startForeground() で MissingForegroundServiceTypeException になります。

type を上から順に見ていきます。

type 判定
mediaPlayback 自アプリで再生する用。何も再生しないので当てはまらない
mediaProjection 画面を外部に映す用。映さない
mediaProcessing 動画変換などの重い処理用。しない
dataSync データ転送・ファイル処理用。どちらもしない
location 位置情報用。使わない
camera / microphone カメラ・マイク用。どちらも使わない
phoneCall 通話用。しない
connectedDevice 外部デバイス接続用。繋がない
health フィットネス用。違う
remoteMessaging メッセージ転送用。しない
shortService 3分で終わる処理用。常駐したいので無理
systemExempted 一般アプリは使えない
specialUse 上のどれにも当てはまらない用途の受け皿。残ったのはこれ

消去法で specialUse です。
既存 type に当てはまらないケースの受け皿ですが、代わりに用途を自分で説明する義務がついてきます。

Manifest の宣言

specialUse を使うには、権限2つ・foregroundServiceType・サブタイプの説明文の3点が要ります。

AndroidManifest.xml
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />

<service
    android:name=".service.ChordTapService"
    android:exported="false"
    android:foregroundServiceType="specialUse">
    <property
        android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
        android:value="Observes active media session metadata to keep a persistent chord-search notification" />
</service>

PROPERTY_SPECIAL_USE_FGS_SUBTYPE は自由記述です。
公式ドキュメントによると Play Console 提出時にこの値が審査されるので、なぜ既存 type ではダメなのかが読み取れる文にしました。

通知の中身が決まる前に startForeground を呼ぶ

FGS は起動したらすぐに通知を出さないといけません。
悪いアプリたちが裏でこそこそ作業できなくなっているんですね。
startForegroundService() で起動してから startForeground() を呼ぶまでの猶予は数秒で、間に合わないとアプリごと落ちます。

一方で、通知に出したい「今の曲名」は、起動した瞬間にはまだ分かりません。
設定を読んで、対象アプリの MediaSession を取りに行って、やっと曲名が決まります。
どれも非同期なので、曲名が決まってから通知を出す作りだと猶予を使い切ることがあります。

なので順番を逆にしました。
起動直後にまず「音楽が鳴っていません。」という中身のない通知で startForeground() を済ませ、曲名が分かったら通知を差し替えます。

ChordTapService.kt
override fun onCreate() {
    super.onCreate()
    createChannel()
    // 数秒以内に startForeground を満たすため、まず待機状態の通知で前面化する。
    startAsForeground(buildNotification(null))
    // ... 以降で設定購読・セッション監視を開始
}

private fun startAsForeground(notification: Notification) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
        startForeground(
            NOTIFICATION_ID,
            notification,
            ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
        )
    } else {
        startForeground(NOTIFICATION_ID, notification)
    }
}

API 34 以降は startForeground にも type を渡します。
buildNotification(null) が中身のない通知で、再生停止中にもそのまま使っています。

音楽が鳴っていませんと表示された常駐通知
起動直後と再生停止中はこの表示。まずこれを出して startForeground を満たす

Play Console への申告

Play Console で FGS の type と用途を申告します。
場所は[ポリシーとプログラム]→[アプリのコンテンツ]→[フォアグラウンド サービスの権限]です。

項目 内容
機能の説明 FGS が何をするか
ユーザーへの影響 止まると何が困るか
ユースケース 事前定義リストから選択。なければ手入力
デモ動画 必須。リンクを貼る

デモ動画は、常駐通知の「検索」ボタンを押してブラウザの検索画面に遷移するまでを撮り、Google ドライブに限定公開でアップしてリンクを貼りました。
凝ったものは不要で、機能をトリガーする操作が最初から最後まで映っていれば足りるようです。

実際に提出した申告文(全文)

本アプリは、ユーザーが音楽アプリ(YouTube / Spotify)で再生中の曲のギターコードを、ワンタップで検索できるようにするアプリです。

フォアグラウンドサービスは、端末上のアクティブなメディアセッションのメタデータ(曲名・アーティスト名)を継続的に監視し、現在再生中の曲を表示する常駐通知(コード検索ボタン付き)を最新の状態に維持するために使用します。

このタスクは一時停止や再起動ができません。再生中の曲はいつでも切り替わるため、サービスを一時停止すると通知の曲情報が古いままになり、ユーザーが検索ボタンをタップした際に、すでに再生が終了した別の曲のコードを検索してしまう可能性があります。

本機能の正しさは、メディアセッションの変化を途切れなく追従し、通知に表示する曲情報を常に最新の状態に保つことに依存しています。

サービスは、ユーザーの明示的な操作によってのみ開始され、ユーザーはいつでも停止できます。

既存のフォアグラウンドサービスタイプには、本用途である「メディアセッションのメタデータ監視」に該当するものがありません。本アプリ自体は音声の再生を行わないため、mediaPlayback にも該当しません。

そのため、フォアグラウンドサービスタイプとして specialUse を使用しています。用途の詳細は、AndroidManifest.xml の PROPERTY_SPECIAL_USE_FGS_SUBTYPE に宣言済みです。

申告文で意識したのは3点です。

  1. なぜ中断できないかを、具体的な壊れ方で書く。 「常に最新である必要がある」だけだと抽象的です。止まると何が起きるか(通知が古い曲のまま → ユーザーが押すと終わった曲のコードを検索してしまう)まで書きました
  2. mediaPlayback ではない理由を名指しで否定する。 「該当する type がない」で終わらせず、自アプリは音声を再生しないと明記しました。ここが specialUse 固有で、一番効く部分だと思います
  3. Manifest の PROPERTY_SPECIAL_USE_FGS_SUBTYPE と揃える。 コンソール側とコード側の説明が一致していることを示しました

あわせて「ユーザーの操作でのみ開始し、いつでも停止できる」ことも書き、勝手に常駐するアプリではないと明示しました。

まとめ

  • 自アプリが再生しない「監視するだけ」の常駐通知は mediaPlayback を名乗れず、specialUse になる
  • Manifest では FOREGROUND_SERVICE_SPECIAL_USE 権限と PROPERTY_SPECIAL_USE_FGS_SUBTYPE をセットで宣言する
  • 通知の中身が決まる前に、空の通知で startForeground を済ませる
  • Play Console の申告文は「止まると何が壊れるか」と「なぜ mediaPlayback ではないか」を具体的に書く。これで一発で通った

触れなかった点として、常駐通知のボタンからブラウザを開く部分は targetSdk 31(Android 12)以上の通知トランポリン規制に当たります。
通知 → Service / Receiver → Activity 起動は禁止なので、PendingIntent.getActivity() で透明な Activity を直接起動しています。
こちらは公式ドキュメントどおりの対処なので省略しました。

specialUse は「用途を説明する義務」がついてくる代わりに、既存 type に無理やり当てはめるより素直に書けます。
同じカテゴリのアプリを作る人の参考になれば幸いです。

参考

https://developer.android.com/develop/background-work/services/fgs/service-types

Discussion