Android: 設定画面と設定情報をダイレクトブート対応にする

Android では設定画面を簡単に作れるけど、どうすればダイレクトブート対応にできるかの解説があまり見当たらなかったので、調べてみた。

背景

Android では、SharedPreferences クラスで設定値の読み書きができるほか、PreferenceActivityPreferenceFragment などのクラスで設定画面も簡単に作れる。

ところが、Android 7.0 (Nougat) 以降で、ダイレクトブート (direct boot) という機能が導入された。端末の起動直後は、端末暗号化ストレージ (device encrypted storage) のファイルにしかアクセスしかない。ユーザーが端末をアンロックすると、認証情報暗号化ストレージ (credential encrypted storage) にもアクセスできるようになる。

これで困るのが、バックグラウンドで何かしたいアプリだ。例えば AlarmManager に登録したアラームは永続化されないため、再起動すると消えてしまう。そのため、再起動後にアラームを登録し直す必要があるんだけど、起動直後は端末暗号化ストレージにしかアクセスできない。加えて、その後もアンロックされなければ、アラームで設定した時間になっても同じかもしれない。

だからアラームの情報は端末暗号化ストレージに持ちたいのだけど、設定画面を普通に作ると認証情報暗号化ストレージに保存されてしまう。

端末暗号化ストレージに保存されるようにするには、プログラムで対応する必要があるようだ。

対応方法

ストレージ切り替えの準備

端末暗号化ストレージは Android 7.0 (Nougat) より前のバージョンでは使えない。それもサポートするためには、Androidバージョンによって保存先を切り替える必要がある。

切り替えるには、設定情報を読み書きするときに普通の Context そのものではなく、createDeviceProtectedStorageContext メソッドを呼んで得られたものを使う。

ただ、バージョンの判定をいちいちやるのは面倒なので、先に作っておく。

Kotlin の拡張メソッドを使うとこんな感じ。

fun Context.createPreferenceStorageContext(): Context {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        createDeviceProtectedStorageContext()
    } else {
        this
    }
}

Java だったら static メソッドでこんな感じだろうか。

public static createPreferenceStorageContext(Context context) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        return context.createDeviceProtectedStorageContext();
    } else {
        return context;
    }
}

設定画面の修正

設定画面の読み書き先を端末暗号化ストレージに切り替えるには、設定画面の Fragment で、PreferenceManagersetStorageDeviceProtected メソッドを呼ぶ。

設定情報へのアクセスが発生する前に呼んでおく必要があるので、onCreate メソッドの最初が安全そうだ。

このメソッドは Android 7.0 (Nougat) 以降にしかないので、以前のバージョンもサポートする場合には if 文で囲む必要がある。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        preferenceManager.setStorageDeviceProtected()
    }

    // 以下略
}

また、設定画面の中で、他にも設定情報を見ている箇所を探して、端末暗号化ストレージを使えるように修正する必要がある。

例えば、設定値のサマリーを表示するために、設定情報を呼んでいる箇所がそうだ。

sBindPreferenceSummaryToValueListener.onPreferenceChange(preference,
        PreferenceManager
                .getDefaultSharedPreferences(preference.context)
                .getString(preference.key, ""))

このうち preference.context の部分をそのままにすると、認証情報暗号化ストレージから値を取ろうとしてしまう。そこで、先ほど作ったストレージ判定メソッドを使って、端末暗号化ストレージを使うようにする。

sBindPreferenceSummaryToValueListener.onPreferenceChange(preference,
        PreferenceManager
                .getDefaultSharedPreferences(preference.context.createPreferenceStorageContext())
                .getString(preference.key, ""))

Java の場合は、preference.getContext()createPreferenceStorageContext(preference.getContext()) のように直すことになるだろう。

これで、設定画面は端末暗号化ストレージを使えるようになる。

設定を自前で読み書きするコードの修正

設定画面以外で、設定値を読み書きしている箇所を修正していく。

具体的には、SharedPreferences を取得している箇所を探す。

val prefs = PreferenceManager.getDefaultSharedPreferences(context)

この引数に渡している Context を、やはり先ほどと同じように自作のメソッドで置き換えていく。

val prefs = PreferenceManager.getDefaultSharedPreferences(
    context.createPreferenceStorageContext())

Java だとこんな感じ。

SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(
    createPreferenceStorageContext(context));

ストレージの移行

認証情報暗号化ストレージに保存していたファイルを、端末暗号化ストレージに移さないといけない場合がある。

ユーザーが端末の OS をバージョンアップしたとか、以前からあるアプリが新たにダイレクトブートに対応するとか。

これは、Context クラスの moveSharedPreferencesFrom メソッドで行える。設定情報のファイルを、端末暗号化ストレージに移動してくれる。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
    val srcContext = this
    val destContext = context.createPreferenceStorageContext()
    val prefName = PreferenceManager.getDefaultSharedPreferencesName(srcContext)
    destContext.moveSharedPreferencesFrom(srcContext, prefName)
}

参考にしたサイト

Google によるサンプルコード

ダイレクトブート対応で、設定情報の取得・参照や、端末起動の検知を行う場合のコード例。

github.com

Google の開発者向けガイド

ダイレクトブートについての説明。注意点なども載っている。ただし、途中のコード例に致命的な誤植があるので、英語版も合わせて参照したい。

developer.android.com