WebViewのメディアキャプチャ権限リクエストをフレームワーク側で処理
Webページが getUserMedia({ audio, video }) を呼び出した際のパーミッション処理が、ホストアプリ側の実装なしにフレームワーク側で完結するようになりました。新設された WebViewPermissionDelegate が WebChromeClient.onPermissionRequest を透過的に処理します。
背景
これまで、Webページからカメラやマイクへのアクセスを要求する getUserMedia() を扱うには、ホストアプリが WebChromeClient.onPermissionRequest を独自にオーバーライドし、Androidのランタイムパーミッション取得からWebViewへの結果返却までを自前で実装する必要がありました。位置情報については既に GeolocationPermissionDelegate によって同様の抽象化が提供されていましたが、カメラ・マイクに相当する仕組みが欠けていました。
本PRはその非対称性を解消し、メディアキャプチャ権限の処理をフレームワーク側に集約します。
技術的な変更
WebViewPermissionDelegate の新設
WebViewPermissionDelegate(core/src/main/kotlin/dev/hotwire/core/files/delegates/WebViewPermissionDelegate.kt)が新設され、Session に webViewPermissionDelegate プロパティとして保持されます。このクラスが処理するリソースは以下の2種類です。
-
RESOURCE_AUDIO_CAPTURE:RECORD_AUDIO(ランタイム権限)とMODIFY_AUDIO_SETTINGS(インストール時権限)の両方がマニフェストに宣言されていることが必要 -
RESOURCE_VIDEO_CAPTURE:CAMERA(ランタイム権限)が必要
MODIFY_AUDIO_SETTINGS がインストール時権限でありながら必須とされている理由は、Chromium の音声パイプラインが通信デバイスの選択に同権限を要求するためです。RECORD_AUDIO のみ付与された状態では Unable to select communication device! エラーが発生し、getUserMedia({ audio: true }) が失敗します。マニフェスト宣言が不足している場合はリクエスト全体を即座に拒否し、ページ側には NotAllowedError として伝達されます。
キャンセル安全性にも考慮されており、pendingRequest フィールドで保持中のリクエストを追跡します。新しいリクエストが来た際は既存の保留リクエストを先に拒否してから新しいものを保持し、孤立したリクエストが無応答になる状態を防いでいます。
HotwireWebChromeClient への統合
HotwireWebChromeClient に onPermissionRequest と onPermissionRequestCanceled のオーバーライドが追加されました。
override fun onPermissionRequest(request: PermissionRequest) {
if (request.requestsMediaCapture()) {
session.webViewPermissionDelegate.onRequest(request)
} else {
super.onPermissionRequest(request)
}
}
override fun onPermissionRequestCanceled(request: PermissionRequest) {
session.webViewPermissionDelegate.onCancel(request)
super.onPermissionRequestCanceled(request)
}
private fun PermissionRequest.requestsMediaCapture(): Boolean {
val resources = resources ?: return false
return resources.isNotEmpty() && resources.all { resource ->
resource == PermissionRequest.RESOURCE_AUDIO_CAPTURE ||
resource == PermissionRequest.RESOURCE_VIDEO_CAPTURE
}
}
メディアキャプチャ以外のリソース(RESOURCE_PROTECTED_MEDIA_ID 等)は super に委譲されるため、既存の動作を壊しません。キャンセル時は常にデリゲートへ転送し、デリゲート側で追跡中のリクエストに一致しない場合は no-op として扱われます。
複数権限ランチャーの追加
音声と映像を同時に要求するケースでは、RECORD_AUDIO と CAMERA を一度のシステムダイアログで要求する必要があります。これに対応するため、VisitDestination インターフェースと HotwireDestination インターフェースに activityMultiplePermissionsResultLauncher メソッドが追加されました。
fun activityMultiplePermissionsResultLauncher(
requestCode: Int
): ActivityResultLauncher<Array<String>>? = null
既存の activityPermissionResultLauncher(単一権限用)と対になる形で設計されており、デフォルト実装は null を返します。HotwireWebFragment と HotwireWebBottomSheetFragment の両フラグメントがこのメソッドをオーバーライドし、HOTWIRE_REQUEST_CODE_WEBVIEW_PERMISSION(値: 3738)に対して HotwireWebFragmentDelegate 内で登録された webViewPermissionResultLauncher を返します。
private fun registerWebViewPermissionLauncher(): ActivityResultLauncher<Array<String>> {
return navDestination.fragment.registerForActivityResult(RequestMultiplePermissions()) { results ->
session.webViewPermissionDelegate.onActivityResult(results)
}
}
RequestMultiplePermissions コントラクトを使用することで、複数のランタイム権限を一括リクエストし、その結果を Map<String, Boolean> として受け取ってデリゲートに委譲します。
設計判断
既存の GeolocationPermissionDelegate と対称的な設計 が採用されています。Session にデリゲートを保持し、HotwireWebChromeClient がルーティングし、フラグメント側がランチャーを登録するという責務分担は、位置情報権限の実装と完全に対応しています。
リクエストコードの管理も既存パターンを踏襲しており、FileConstants.kt に HOTWIRE_REQUEST_CODE_GEOLOCATION_PERMISSION = 3737 に続く形で HOTWIRE_REQUEST_CODE_WEBVIEW_PERMISSION = 3738 が追加されました。単一権限と複数権限でランチャーのインターフェースを分けた点は、ActivityResultContracts.RequestPermission と RequestMultiplePermissions がそれぞれ異なる型シグネチャ(ActivityResultLauncher<String> vs ActivityResultLauncher<Array<String>>)を持つためで、型安全性を保つ上で合理的な判断です。
ホストアプリへの要求は最小限に抑えられており、マニフェストへの権限宣言のみで対応できます。既存の activityMultiplePermissionsResultLauncher のデフォルト実装が null を返すため、メディアキャプチャが不要なアプリへの影響はありません。
まとめ
この変更により、getUserMedia() を使用するWebページのサポートがホストアプリのボイラープレートなしに実現できるようになりました。GeolocationPermissionDelegate で確立されたパターンをメディアキャプチャへ一貫して適用することで、フレームワークのパーミッション処理の統一性が高まっています。