こんにちは、AndroidアプリエンジニアのChigitaです。
この記事では、SAF(Storage Access Framework)における DocumentsProvider に焦点を絞り、LINEアプリを開発していく中で培った 1. 安全にファイルアクセスを行う工夫や、2. 開発を進めるうえで注意するべき点について紹介します。
SAFとは
Androidでは、SAFと呼ばれるフレームワークがあります。SAFは実際のデータの所在を意識することなく、まるでひとつのディレクトリ以下にすべてのデータが配置してあるかのようにデータアクセスを行うことができるものです。
SAFは大きく分けて以下の3つの要素から構成されています。
DocumentsProvider
DocumentsProvider は各アプリが Client App に対してデータを提供するために実装するものです。DocumentsProvider は ContentProvider の一種です。実際のクラスでも、DocumentsProvider は ContentProvider を継承して実現されています。
しかし、ContentProvider と異なる点は、データ共有のためのスキーマがほとんど固定の形式で決まっていることです。これは、DocumentsProvider が他のアプリと主にファイルやドキュメントを共有することに特化しているためです。
Client App
複数の DocumentsProvider からデータを取得するためのアプリケーションです。リクエストは Intent を用いて行われます。
Picker System UI
Client Appの検索結果からDocumentsProviderが提供するデータにアクセスできるようにするSystemのUIです。
構成要素を理解するための例として、ファイル管理アプリを考えます。DocumentsProvider はファイルデータを提供します。local / remote問わずさまざまなストレージに保存されています。
それぞれのストレージを扱うアプリが DocumentsProvider を実装する必要があります。Client App は、DocumentsProvider からデータを取得し、ファイルを表示します。Client App と 各 DocumentsProvider を結ぶのが Picker System UI です。
今回のブログでは、特に Client App に対してデータを提供する DocumentsProvider について詳細に紹介します。
DocumentsProvider の実装
カスタムの DocumentsProvider を作成するには最低限以下の4つのメソッドを実装する必要があります。
- queryRoots
- queryDocument
- queryChildDocuments
- openDocument
ここでは、いくつか実装のポイントのみを紹介します。特に安全なデータアクセスの観点で重要な点については後述します。
より詳細な実装方法の説明は、Android Developers に記載されているためこちらをご参照ください。
queryRootsメッソッドの実装
DocumentsProvider が Client App に対して提供するデータのルートを定義し、取得するメソッドです。Rootは複数定義できます。ドキュメントでは複数アカウントを切り分けるときに複数のRootを定義することが一般的です。
しかし、より抽象的にこの概念を考え、データアクセスを切り分けたい単位でRootを定義し、Client App に提供できます。このようにすると、状態に応じてRootを切り替えることで、データアクセスを制御できます。
override fun queryRoots(projection: Array<out String>?): Cursor {
// 1. 現在のアプリの状態を取得
val currentAppState = getCurrentAppState()
// 2. 現在のアプリの状態から許可されるべき Scope (Root) を取得
val allowedScopes = MyAppScope.entries.filter { canAccessRoot(it, currentAppState) }
val matrixCursor = MatrixCursor(resolveRootProjection(projection))
for (scope in allowedScopes) {
// 3. Rootを Cursor に追加
matrixCursor.newRow().apply {
// 4. 追加するRootの情報を設定
}
}
return matrixCursor
}
enum class MyAppScope {
SCOPE_A,
SCOPE_B,
SCOPE_C
}
DocumentsProvider のテストを書く
前述の通り、DocumentsProvider は ContentProvider を継承しているため、ContentProvider のテストを書く際に使用している方法が有効です。以前は ProviderTestCase2 や ProviderTestRule を使うような手法が一般的でしたが、現在はこれらのAPIは削除されたり、非推奨となっています。
現在は Fake を実装しこれをテストする方法が推奨されています。Fake を実装することで、テスト時には実際のデータを使用せず、テスト用のデータを使用できます。
このようにプラットフォームの制約によりテストを書きにくいクラスの場合、このクラス記述するロジックを最小限にし、コアのロジックは他クラスに移譲します。そうすることで、ロジックのテストは各クラスに分割して行うことができるため、テスタブルな構造にすることができます。
安全にデータを提供するための工夫
適切なタイミングでコンポーネントを有効・無効にする
DocumentsProvider は ContentProvider を継承しているため enabled 属性を設定できます。ユースケースによっては、DocumentsProvider そのものへのアクセスを許可・不許可を動的に切り替えることができると、意図しないタイミングでのアクセスを防止できます。動的に切り替える場合には、PackageManagerから設定値を取得できます。以下が実装例です。
fun enableMyCustomDocumentProvider() {
val packageManager = context.packageManager
// 1. 実装している DocumentsProvider のidentifier を取得
val component = ComponentName(
context,
MyCustomDocumentProvider::class.java
)
// 2. 現在の設定値を確認
val wasEnabled = packageManager.getComponentEnabledSetting(component) == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
if (!wasEnabled) {
// 3. MyCustomDocumentProvider を有効にする
packageManager.setComponentEnabledSetting(
component,
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP
)
}
}
特定のアプリケーションにのみアクセスを許可する
DocumentsProvider では特定の Client App にのみアクセス許可を与える実装が可能です。
以下の2点の実装を行うことで、安全にファイルアクセスを許可できます。
一方でこれらの方法は基本的には AllowList 形式での設定が必要であるため、その数が増えると設定がやや複雑になる傾向にあります。
1. Client App のパッケージ名/署名を検証する
callingPackage を onCreate 以降であれば、取得できます。
callingPackage からパッケージ名・Signature(署名)を参照し、これを検証することで特定のアプリケーションの場合のみに処理を進める。あるいは、特定のアプリケーションからの呼び出しには、特別な振る舞いを提供することも可能です。
ここではパッケージ名を検証する方法について記載します。基本的には callingPackage を通して得られるパッケージ名と許可したいパッケージ名を比較するのみで十分ですが、sharedUserIdを使用しているケースを考慮して、UIDからパッケージ名を取得する方法を紹介します。
const val ALLOWED_PACKAGE_NAME = "com.example.trustedapp"
fun isAllowedCaller(): Boolean {
// 1. 呼び出しを行ったアプリケーションの UID を取得
val uid = packageManager.getPackageUid(callingPackage, PackageManager.PackageInfoFlags.of(0))
// 2. UID に紐づくパッケージ名の一覧を取得
val packageNameSet = packageManager.getPackagesForUid(uid)?.toSet() ?: return false
// 3. 許可したいパッケージ名を含んでいるかを検証
return packageNameSet.contains(ALLOWED_PACKAGE_NAME)
}
この検証を、DocumentsProvider の queryChildDocuments や openDocument、createDocument、deleteDocumentなどの各メソッドの処理を実行する前に行います。この検証を行うことで、悪意を持ったSpoofing Client Appを通して処理が実行されることを防げます。
2. 適切なファイルアクセスパーミッションを設定する
DocumentsProviderの一連のフローでは、Client Appに提供するドキュメントファイルへのアクセスの権限を設定できます。この権限は、DocumentsContract.Document.COLUMN_FLAGS
によって設定できます。これは、Uriベースでアクセス権限を付与するもののラッパーのような形になっています。
例えば、FLAG_SUPPORTS_WRITE
を COLUMN_FLAGS
に対して指定した場合、Intent#FLAG_GRANT_READ_URI_PERMISSION
と Intent#FLAG_GRANT_WRITE_URI_PERMISSION
が documentId から構築される uri に対して付与されます。COLUMN_FLAGS
に設定できる詳細な値はこちらを参照してください。
一般に、フラグ指定をする際に不要なフラグを付与することは避けるべきです。例えば、Client Appからデータを削除されたくない場合には FLAG_SUPPORTS_DELETE
を付与することは避けるべきです。
必要な範囲で必要最小限のフラグを指定することで、安全にデータを提供できます。また、特定の Client App に対して特別な振る舞いが必要な場合には、パッケージ名を検証する方法を用いることが可能です。
おわりに
DocuementsProvider の実装や解説の例は少なく、開発を進めるうえで未知の問題に直面することが多いと感じました。この記事が、DocumentsProvier を用いる開発において参考になれば幸いです。
参考(外部サイト)
- https://developer.android.com/guide/topics/providers/create-document-provider
- https://developer.android.com/reference/android/provider/DocumentsProvider
- https://developer.android.com/reference/android/provider/DocumentsContract
- https://developer.android.com/reference/android/provider/DocumentsContract.Document
- https://github.com/android/storage-samples/tree/main/StorageProvider