🛎️

Androidの通知音の設定を自動化したかっただけなのに、OSのバグとか見つけた話

に公開

はじめに

Fairy Devices株式会社のKabochaです。
Androidには、デフォルトで設定されている通知音があります。
これは設定アプリから、プリセットされているものや、ユーザーが任意に追加したoggファイルを通知音として設定できます。
Android公式の設定方法の記事

今回は、この通知音を大量の端末で一括設定するケースを考慮し、調査した話をします。

この記事を読んでほしい方

  • 「何十台もの業務用端末に、同じ通知音を手作業で設定するのが大変…」と感じている開発者やIT担当者の方
  • Androidのシステム設定がどのように動いているのか、その仕組みやAOSPのソースコードに興味がある方

調査環境

  • (メイン)Google Pixel 4a (5G) - Android 14
  • (確認用)Google Pixel 7 - Android 16

そもそもなぜ通知音を変更する必要があったのか

デフォルトの通知音が鳴る仕組み

Androidのデフォルトの通知音は、NotificationChannelImportanceIMPORTANCE_DEFAULT または IMPORTANCE_HIGH の場合に再生されます。

val importance = NotificationManager.IMPORTANCE_DEFAULT // もしくは、IMPORTANCE_HIGH
NotificationChannel(CHANNEL_ID, channelName, importance)

変更が必要になるケース

使用するアプリがビルド済みであったり、SDKに組み込まれている機能の場合、このImportanceレベルは変更できません。特定の環境(静かなオフィスや医療現場など)では、デフォルトの通知音が適切でない場合があります。

通常は設定アプリから手動で変更すれば済む話ですが、企業向けに複数のデバイスをキッティングする場合は話が変わります。
数台ならまだしも、数十、数百台の端末で同じ設定を行うために画面をポチポチ操作するのは、非常にコストが掛かります。できればコマンドラインや、専用の設定アプリで一括設定したいところです。

解決策1: コマンドラインで設定する方法

AOSPのコードを読み解くと、通知音の設定はSettingsのContentProviderに保存され、URIで向き先を設定しているようでした。

$ adb shell settings get system notification_sound
content://media/internal/audio/media/81

設定を変更する方法

1. プリセットの音源を使用する場合

すでにOSに入っているものを使う場合は、次のようにURIを変えれば良さそうです。

$ adb shell settings put system notification_sound content://media/internal/audio/media/63

2. カスタム音源を設定する場合

画面操作をせずに、独自の通知音を設定するには、ContentProviderを実装し、そのURIを指定すれば良さそうです。
以下に動作確認につかった実装を提示しておきます。

独自の通知音を提供するContentProviderなどの実装
SystemNotificationProvider.kt
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import androidx.core.net.toUri
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException

/**
 * SystemUIのために用意するシステム通知音データを提供するContentProvider
 * 
 * ## ファイル構造
 * ### Assets構造(ビルド時)
 * ```
 * app/src/main/assets/
 * └── sounds/
 *     └── notification_sound.ogg  # システム通知音(OGG Vorbis形式)
 * ```
 * 
 * ### 内部ストレージ構造(実行時)
 * ```
 * /data/data/ai.fd.thinklet.app.jikken/files/
 * └── system_notification/
 *     └── notification_sound.ogg  # assetsからコピーされた音声ファイル
 * ```
 */
class SystemNotificationProvider : ContentProvider() {
    companion object {
        private const val AUTHORITY = "ai.fd.thinklet.app.jikken.provider"
        private const val SOUND_NAME = "notification_sound"
        private const val SOUND_FILE_NAME = "${SOUND_NAME}.ogg"

        val NOTIFICATION_URI: Uri = "content://$AUTHORITY/$SOUND_NAME".toUri()
    }

    override fun onCreate(): Boolean = true

    override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
        if (uri != NOTIFICATION_URI) {
            throw IllegalArgumentException("Unknown URI: $uri")
        }

        val soundFile = deployAssetSound(SOUND_FILE_NAME)
        
        return ParcelFileDescriptor.open(
            soundFile,
            ParcelFileDescriptor.MODE_READ_ONLY
        )
    }

    override fun getType(uri: Uri): String? {
        return if (uri == NOTIFICATION_URI) "audio/ogg" else null
    }

    /**
     * assetsから内部ストレージにコピーしつつ、そのFilePathを返す関数
     * @param fileName 音声ファイル名(拡張子含む)
     * @return コピー先のFile
     */
    private fun deployAssetSound(fileName: String): File {
        val context = context ?: throw IllegalStateException("Context is null")
        
        val dir = File(context.filesDir, "system_notification")
        if (!dir.exists()) dir.mkdirs()
        
        val destFile = File(dir, fileName)
        
        // 既に存在する場合はそのまま返す
        if (destFile.isFile && destFile.length() > 0L) {
            return destFile
        }
        
        // assetsからコピー
        val assetPath = "sounds/$fileName"
        try {
            context.assets.open(assetPath).use { input ->
                FileOutputStream(destFile).use { output ->
                    input.copyTo(output)
                }
            }
        } catch (e: IOException) {
            throw FileNotFoundException("Asset not found: $assetPath")
        }
        
        return destFile
    }

    override fun query(uri: Uri, projection: Array<out String>?, selection: String?, 
                      selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
    override fun insert(uri: Uri, values: ContentValues?): Uri? = null
    override fun update(uri: Uri, values: ContentValues?, selection: String?, 
                       selectionArgs: Array<out String>?): Int = 0
    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
}
AndroidManifest.xml
<provider
    android:name=".SystemNotificationProvider"
    android:authorities="ai.fd.thinklet.app.jikken.provider"
    android:exported="true"
    android:grantUriPermissions="true" />

そして、アプリをビルドし、以下のコマンドで設定します。

$ adb install jikken.apk
$ adb shell settings put system notification_sound content://ai.fd.thinklet.app.jikken.provider/notification_sound

ほとんどのケースではこれで解決できます。しかし、もっと柔軟な制御が必要な場合があります。

解決策2: アプリから動的に制御する

時間帯や場所によって通知音を変えたい場合、アプリから動的に制御する必要があります。

RingtoneManagerを使った最初の試み

調べてみると、RingtoneManagersetActualDefaultRingtoneUri メソッドが使えそうでした。

fun editViaRingtoneMgr(): Result<Unit> = runCatching {
    RingtoneManager.setActualDefaultRingtoneUri(
        context,
        RingtoneManager.TYPE_NOTIFICATION,
        "content://media/internal/audio/media/63".toUri()
    )
}

上記の通り実装し、実行すると以下の例外が投げられていました。

java.lang.SecurityException: ai.fd.thinklet.app.jikken was not granted this permission: android.permission.WRITE_SETTINGS.

WRITE_SETTINGS権限の問題

Developerサイトには記載がありませんが、WRITE_SETTINGS 権限が必要とのことです。
この権限の定義を見てみると、

<permission android:name="android.permission.WRITE_SETTINGS"
    android:label="@string/permlab_writeSettings"
    android:description="@string/permdesc_writeSettings"
    android:protectionLevel="signature|preinstalled|appop|pre23|role" />

システム系を除き、一般的にはAppOp経由で許可することができます。
許可を得るには、以下を実行し、ユーザーの手動操作が必要です。

fun requestWriteSettingsPermission() {
    val intent = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS)
    intent.data = "package:${context.packageName}".toUri()
    context.startActivity(intent)
}

ACTION_MANAGE_WRITE_SETTINGSの画面

しかし、これでは自動化の目的が達成できません。別の方法を探す必要がありそうでした。

AOSPの実装を調査

RingtoneManagerの実装を見てみます。

RingtoneManager.java
// https://cs.android.com/android/platform/superproject/+/android-14.0.0_r32:frameworks/base/media/java/android/media/RingtoneManager.java;l=1031
public static void setActualDefaultRingtoneUri(Context context, int type, Uri ringtoneUri) {
    String setting = getSettingForType(type);
    if (setting == null) return;

    final ContentResolver resolver = context.getContentResolver();
    if(!isInternalRingtoneUri(ringtoneUri)) {
        ringtoneUri = ContentProvider.maybeAddUserId(ringtoneUri, context.getUserId());
    }

    if (ringtoneUri != null) {
        final String mimeType = resolver.getType(ringtoneUri);
        if (mimeType == null) {
            Log.e(TAG, "setActualDefaultRingtoneUri for URI:" + ringtoneUri
                    + " ignored: failure to find mimeType (no access from this context?)");
            return;
        }
        if (!(mimeType.startsWith("audio/") || mimeType.equals("application/ogg"))) {
            Log.e(TAG, "setActualDefaultRingtoneUri for URI:" + ringtoneUri
                    + " ignored: associated mimeType:" + mimeType + " is not an audio type");
            return;
        }
    }

    Settings.System.putStringForUser(resolver, setting,
            ringtoneUri != null ? ringtoneUri.toString() : null, context.getUserId());
}

なるほど、関数内では Settings.System.putStringForUser、つまり Settings.System.putString を呼んでいるようです。これは 設定を変更する方法 と同じことをしていますね。

SettingsProviderの権限チェック

SettingsProviderの実装を見てみます。

SettingsProvider.java
// https://cs.android.com/android/platform/superproject/+/android-14.0.0_r1:frameworks/base/packages/SettingsProvider/src/com/android/providers/settings/SettingsProvider.java;l=1904
    private boolean mutateSystemSetting(String name, String value, int runAsUserId, int operation,
            boolean overrideableByRestore) {
        final String callingPackage = getCallingPackage();
        if (!hasWriteSecureSettingsPermission()) {
            // If the caller doesn't hold WRITE_SECURE_SETTINGS, we verify whether this
            // operation is allowed for the calling package through appops.
            if (!Settings.checkAndNoteWriteSettingsOperation(getContext(),
                    Binder.getCallingUid(), callingPackage, getCallingAttributionTag(),
                    true)) {
                Slog.e(LOG_TAG, "Calling package: " + callingPackage + " is not allowed to "
                        + "write system settings: " + name);
                return false;
            }
        }
    // ... 省略 ...
}

興味深いことに、WRITE_SECURE_SETTINGS が許可されてないならば、WRITE_SETTINGS を確認するという実行手順になっています。つまり、WRITE_SECURE_SETTINGS でも動作できます。

WRITE_SECURE_SETTINGS権限を使った解決策

WRITE_SECURE_SETTINGS の定義を確認すると

<permission android:name="android.permission.WRITE_SECURE_SETTINGS"
    android:protectionLevel="signature|privileged|development|role|installer" />

development フラグがあるため、ADB経由で権限付与が可能です。

$ adb install -g jikken.apk
# または
$ adb shell pm grant ai.fd.thinklet.app.jikken android.permission.WRITE_SECURE_SETTINGS

これで以下のコードもきちんと動作しました。

fun editViaSettings(): Result<Unit> = runCatching {
    val ret = Settings.System.putString(
        contentResolver,
        Settings.System.NOTIFICATION_SOUND,
        "content://media/internal/audio/media/67"
    )
    if (!ret) {
        throw RuntimeException("Failed to set notification sound")
    }
}

これで、画面操作なしで自動設定が可能になりました!

予期せぬOSバグの発見

Android 14での奇妙な挙動

とはいえ、APIとして提供されている RingtoneManager を使うほうがいいだろうと思い、Android 14で WRITE_SECURE_SETTINGS 権限を付与した状態で RingtoneManager.setActualDefaultRingtoneUri を実行すると、興味深い現象が発生しました。

// WRITE_SECURE_SETTINGS を付与済みの状態で実行
fun editViaRingtoneMgr(): Result<Unit> = runCatching {
    RingtoneManager.setActualDefaultRingtoneUri(
        context,
        RingtoneManager.TYPE_NOTIFICATION,
        "content://media/internal/audio/media/63".toUri()
    )
}
// → SecurityException が発生します

// しかし、設定を確認すると...
$ adb shell settings get system notification_sound
content://media/internal/audio/media/63  // 書き込まれてるようです。

設定は正常に書き込まれているのに、SecurityExceptionが投げられるという矛盾した状態です。

バグの原因調査

Android 16で同じコードを実行すると、例外は発生しませんでした。
Android 14のソースコードを見ていくと、設定の書き込み後にキャッシュ処理があるバージョンがありました。

RingtoneManager.java
Settings.System.putStringForUser(resolver, setting,
        ringtoneUri != null ? ringtoneUri.toString() : null, context.getUserId());

// Stream selected ringtone into cache so it's available for playback
// when CE storage is still locked
if (ringtoneUri != null) {
    final Uri cacheUri = getCacheForType(type, context.getUserId());
    try (InputStream in = openRingtone(context, ringtoneUri);
            OutputStream out = resolver.openOutputStream(cacheUri, "wt")) {
        FileUtils.copy(in, out);
    } catch (IOException e) {
        Log.w(TAG, "Failed to cache ringtone: " + e);
    }
}

書き込みができていて、だけど例外が投げられているので、このキャッシュ処理で例外が発生していると推測できました。

AOSPでの修正確認

さらに、AOSPのコミット履歴を調査すると、該当部分が削除されていました。

https://cs.android.com/android/_/android/platform/frameworks/base/+/cc0de51a7d14d10e6d13e0c234b25e301bcbbdd2

つまり、Android 14の初期バージョンではバグが存在し、最新パッチまたはAndroid 15以降では修正されていそうです。

念の為、AOSPビルドして確認

パッチが適用されたタイミングを見てみると、android-14.0.0_r28 では未適用で、android-14.0.0_r29 以降には適用されているようでした。
そこで、android-14.0.0_r28 を取得し、該当箇所をコメントアウトして、Pixel 4a(5G) に書き込みして確認しました。

今回、疑わしかったキャッシュ機能部分を以下のようにコメントアウト+ビルドして、実機に書き込みました。

RingtoneManager.java
diff --git a/media/java/android/media/RingtoneManager.java b/media/java/android/media/RingtoneManager.java
index 2c18342af94d..5baccaf4eb8e 100644
--- a/media/java/android/media/RingtoneManager.java
+++ b/media/java/android/media/RingtoneManager.java
@@ -846,15 +846,15 @@ public class RingtoneManager {

         // Stream selected ringtone into cache so it's available for playback
         // when CE storage is still locked
-        if (ringtoneUri != null) {
-            final Uri cacheUri = getCacheForType(type, context.getUserId());
-            try (InputStream in = openRingtone(context, ringtoneUri);
-                    OutputStream out = resolver.openOutputStream(cacheUri, "wt")) {
-                FileUtils.copy(in, out);
-            } catch (IOException e) {
-                Log.w(TAG, "Failed to cache ringtone: " + e);
-            }
-        }
+        //if (ringtoneUri != null) {
+        //    final Uri cacheUri = getCacheForType(type, context.getUserId());
+        //    try (InputStream in = openRingtone(context, ringtoneUri);
+        //            OutputStream out = resolver.openOutputStream(cacheUri, "wt")) {
+        //        FileUtils.copy(in, out);
+        //    } catch (IOException e) {
+        //        Log.w(TAG, "Failed to cache ringtone: " + e);
+        //    }
+        //}
     }

     private static boolean isInternalRingtoneUri(Uri uri) {

その後、WRITE_SECURE_SETTINGS だけ許可して、RingtoneManager.setActualDefaultRingtoneUri を呼び出したところ、予想通り例外は投げられなくなりました。

まとめ

通知音の自動設定という単純な要件から始まった調査は、Androidの権限システムやAOSPの内部実装まで探ることとなり、最終的にはOSのバグ発見に至りました。
今回の調査結果から、通知音を自動で設定したい場合のベストプラクティスは以下の通りです。

  • (1度設定するだけでOKな場合)コマンドラインから設定
    • adb shell settings put system notification_sound <URI> を使えば、プリセット音源もカスタム音源も簡単に設定できます。
  • (動的に設定が必要な場合)アプリから設定
    • WRITE_SETTINGS 権限をアプリから要求する場合、ユーザーの画面操作が必要です。WRITE_SECURE_SETTINGS 権限ならば、ADB経由でアプリに付与ができます。
    • RingtoneManager はAndroid 14の特定バージョンでバグが存在するため、Settings.System.putString を直接呼び出すほうが、安定するかもしれません。
フェアリーデバイセズ公式

Discussion