-
Notifications
You must be signed in to change notification settings - Fork 0
fix: カメラ権限の要求とWebViewの権限許可を見直す #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,7 @@ import io.doany.lockview.kiosk.KioskModeController | |
| import io.doany.lockview.kiosk.KioskSettings | ||
| import io.doany.lockview.kiosk.SystemBarBlockerService | ||
| import io.doany.lockview.kiosk.UnlockSequenceDetector | ||
| import io.doany.lockview.kiosk.WebPermissionPolicy | ||
|
|
||
| class MainActivity : AppCompatActivity() { | ||
|
|
||
|
|
@@ -37,13 +38,17 @@ class MainActivity : AppCompatActivity() { | |
|
|
||
| private var unlockDialog: AlertDialog? = null | ||
|
|
||
| /** カメラ権限の応答を待っている、ページからの権限要求。 */ | ||
| private var pendingPermissionRequest: PermissionRequest? = null | ||
|
|
||
| private val requestCameraPermission = | ||
| registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> | ||
| if (granted) { | ||
| continueStartup() | ||
| } else { | ||
| Toast.makeText(this, R.string.camera_permission_required, Toast.LENGTH_LONG).show() | ||
| val request = pendingPermissionRequest ?: return@registerForActivityResult | ||
| pendingPermissionRequest = null | ||
| if (!granted) { | ||
| Toast.makeText(this, R.string.camera_permission_denied, Toast.LENGTH_LONG).show() | ||
| } | ||
| respondToPermissionRequest(request, granted) | ||
| } | ||
|
|
||
| private val openSetup = | ||
|
|
@@ -65,28 +70,62 @@ class MainActivity : AppCompatActivity() { | |
| webView = findViewById(R.id.webview) | ||
| configureWebView() | ||
|
|
||
| if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) | ||
| == PackageManager.PERMISSION_GRANTED | ||
| ) { | ||
| continueStartup() | ||
| } else { | ||
| requestCameraPermission.launch(Manifest.permission.CAMERA) | ||
| } | ||
| // カメラは表示するページが使う場合にのみ必要なので、ここでは要求しない。 | ||
| continueStartup() | ||
| } | ||
|
|
||
| @SuppressLint("SetJavaScriptEnabled") | ||
| private fun configureWebView() { | ||
| webView.webChromeClient = object : WebChromeClient() { | ||
| override fun onPermissionRequest(request: PermissionRequest) { | ||
| // WebView 内でカメラを使うため、アプリに付与済みの権限をそのまま渡す。 | ||
| request.grant(request.resources) | ||
| onWebPermissionRequest(request) | ||
| } | ||
| } | ||
| webView.settings.javaScriptEnabled = true | ||
| // Vue 等のフレームワークで使うため DOM Storage を有効化する。 | ||
| webView.settings.domStorageEnabled = true | ||
| } | ||
|
|
||
| /** | ||
| * WebView 内のページからの権限要求を処理する。 | ||
| * | ||
| * カメラ権限は、ページが実際に要求した時点で初めてユーザーへ求める。 | ||
| * カメラを使わないページを表示するだけなら、権限を求めずに済む。 | ||
| */ | ||
| private fun onWebPermissionRequest(request: PermissionRequest) { | ||
| if (isCameraGranted()) { | ||
| respondToPermissionRequest(request, isCameraGranted = true) | ||
| return | ||
| } | ||
| if (!WebPermissionPolicy.requestsCamera(request.resources)) { | ||
| // カメラ以外は許可しないので、権限を求める必要もない。 | ||
| request.deny() | ||
| return | ||
| } | ||
| // 権限ダイアログを重ねて出さないよう、応答待ちの間は新しい要求を取り下げる。 | ||
| // 拒否されたページは、必要であれば改めて要求してくる。 | ||
| if (pendingPermissionRequest != null) { | ||
| request.deny() | ||
| return | ||
| } | ||
| pendingPermissionRequest = request | ||
| requestCameraPermission.launch(Manifest.permission.CAMERA) | ||
| } | ||
|
|
||
| /** 許可してよいリソースだけを [PermissionRequest] へ返す。 */ | ||
| private fun respondToPermissionRequest(request: PermissionRequest, isCameraGranted: Boolean) { | ||
| val allowed = WebPermissionPolicy.allowedResources(request.resources, isCameraGranted) | ||
| if (allowed.isEmpty()) { | ||
| request.deny() | ||
| } else { | ||
| request.grant(allowed) | ||
| } | ||
| } | ||
|
|
||
| private fun isCameraGranted(): Boolean = | ||
| ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == | ||
| PackageManager.PERMISSION_GRANTED | ||
|
|
||
| private fun continueStartup() { | ||
| val uri = intent.data | ||
| if (uri != null) { | ||
|
|
@@ -260,6 +299,8 @@ class MainActivity : AppCompatActivity() { | |
| } | ||
|
|
||
| override fun onDestroy() { | ||
| pendingPermissionRequest?.deny() | ||
| pendingPermissionRequest = null | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 要確認(前回レビューの再掲・未対応): その場合、システムのカメラ権限ダイアログ自体は継続表示されますが、 今回のコミット (008c99a) で
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. この点は前回のスレッド(#17 (comment) )で回答済みです。意図的に変更していません。 要点だけ再掲します。同じ再生成によって
ユーザー体験も破綻しません。読み込み直されたページが再度カメラを要求した時点で |
||
| unlockDialog?.dismiss() | ||
| unlockDialog = null | ||
| super.onDestroy() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| package io.doany.lockview.kiosk | ||
|
|
||
| /** | ||
| * WebView 内のページから要求された権限のうち、どれを許可してよいかの判定。 | ||
| * | ||
| * Android に依存しないので、ローカルユニットテストで検証できる。 | ||
| */ | ||
| object WebPermissionPolicy { | ||
|
|
||
| /** `android.webkit.PermissionRequest.RESOURCE_VIDEO_CAPTURE` と同じ値。 */ | ||
| const val RESOURCE_VIDEO_CAPTURE = "android.webkit.resource.VIDEO_CAPTURE" | ||
|
|
||
| /** | ||
| * 要求されたリソースのうち、許可してよいものだけを返す。 | ||
| * | ||
| * アプリがマニフェストで宣言しているのはカメラだけなので、映像の取得だけを、 | ||
| * 端末の権限が付与されている場合に限って許可する。音声の取得や保護されたメディアなどは | ||
| * そもそも宣言しておらず許可できないため、要求されても通さない。 | ||
| * | ||
| * @param requested ページが要求したリソース。 | ||
| * @param isCameraGranted アプリにカメラ権限が付与されているか。 | ||
| * @return 許可してよいリソース。ひとつも無い場合は空。 | ||
| */ | ||
| fun allowedResources(requested: Array<String>, isCameraGranted: Boolean): Array<String> { | ||
| if (!isCameraGranted) { | ||
| return emptyArray() | ||
| } | ||
| return requested.filter { it == RESOURCE_VIDEO_CAPTURE }.toTypedArray() | ||
| } | ||
|
|
||
| /** | ||
| * 要求にカメラの利用が含まれるか。 | ||
| * | ||
| * 含まれない要求は許可する余地が無いので、カメラ権限を要求する必要もない。 | ||
| */ | ||
| fun requestsCamera(requested: Array<String>): Boolean = | ||
| requested.contains(RESOURCE_VIDEO_CAPTURE) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| package io.doany.lockview.kiosk | ||
|
|
||
| import org.junit.Assert.assertArrayEquals | ||
| import org.junit.Assert.assertFalse | ||
| import org.junit.Assert.assertTrue | ||
| import org.junit.Test | ||
|
|
||
| /** [WebPermissionPolicy] のローカルユニットテスト。 */ | ||
| class WebPermissionPolicyTest { | ||
|
|
||
| private val video = WebPermissionPolicy.RESOURCE_VIDEO_CAPTURE | ||
| private val audio = "android.webkit.resource.AUDIO_CAPTURE" | ||
| private val protectedMedia = "android.webkit.resource.PROTECTED_MEDIA_ID" | ||
| private val midiSysex = "android.webkit.resource.MIDI_SYSEX" | ||
|
|
||
| /** カメラ権限があれば映像の取得だけを許可する。 */ | ||
| @Test | ||
| fun allowsVideoCaptureWhenCameraIsGranted() { | ||
| assertArrayEquals( | ||
| arrayOf(video), | ||
| WebPermissionPolicy.allowedResources(arrayOf(video), isCameraGranted = true) | ||
| ) | ||
| } | ||
|
|
||
| /** カメラ権限が無ければ何も許可しない。 */ | ||
| @Test | ||
| fun allowsNothingWhenCameraIsNotGranted() { | ||
| assertArrayEquals( | ||
| emptyArray<String>(), | ||
| WebPermissionPolicy.allowedResources(arrayOf(video), isCameraGranted = false) | ||
| ) | ||
| } | ||
|
|
||
| /** 宣言していない権限は、カメラ権限があっても許可しない。 */ | ||
| @Test | ||
| fun neverAllowsUndeclaredResources() { | ||
| assertArrayEquals( | ||
| emptyArray<String>(), | ||
| WebPermissionPolicy.allowedResources( | ||
| arrayOf(audio, protectedMedia, midiSysex), | ||
| isCameraGranted = true | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| /** 許可できるものと出来ないものが混ざっていても、前者だけを通す。 */ | ||
| @Test | ||
| fun filtersOutUndeclaredResourcesFromMixedRequest() { | ||
| assertArrayEquals( | ||
| arrayOf(video), | ||
| WebPermissionPolicy.allowedResources( | ||
| arrayOf(audio, video, protectedMedia), | ||
| isCameraGranted = true | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun allowsNothingForEmptyRequest() { | ||
| assertArrayEquals( | ||
| emptyArray<String>(), | ||
| WebPermissionPolicy.allowedResources(emptyArray(), isCameraGranted = true) | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun detectsWhetherCameraIsRequested() { | ||
| assertTrue(WebPermissionPolicy.requestsCamera(arrayOf(video))) | ||
| assertTrue(WebPermissionPolicy.requestsCamera(arrayOf(audio, video))) | ||
| assertFalse(WebPermissionPolicy.requestsCamera(arrayOf(audio))) | ||
| assertFalse(WebPermissionPolicy.requestsCamera(emptyArray())) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
要確認: 画面回転などの設定変更で
Activityが再生成される場合もonDestroy()は呼ばれ、pendingPermissionRequest?.deny()が実行されます。しかしシステムのカメラ権限ダイアログ自体は再生成後の新しいインスタンスに引き継がれて表示され続けるため、ユーザーが「許可」を選んでも、ページ側にはすでにdeny()済みとして応答されてしまいます(新インスタンスのpendingPermissionRequestはnullのため、後から届くrequestCameraPermissionのコールバックは何もせず無視されます)。キオスク端末で画面回転が実質発生しない運用であれば実害は小さいですが、
AndroidManifest.xmlに向きの固定(android:screenOrientationなど)が見当たらないため、回転可能な端末では「許可したのに使えない」という体験になり得ます。設定変更による再生成と本当にアプリを離れる場合(isFinishing/isChangingConfigurations)を区別するか、pendingPermissionRequestを引き継ぐ対応が要るかもしれません。There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
設定変更で
onDestroy()が走りdeny()されるという指摘は、そのとおりです。ただ実害は無いと判断し、この点は変更していません。理由は、同じ再生成によって
PermissionRequestの送り主であるページ自体が失われるためです。AndroidManifest.xmlのMainActivityにandroid:configChangesの指定が無いため、回転時はActivityが破棄・再生成されます。WebViewはactivity_main.xmlからfindViewByIdで取得している通常のビューで、状態の保存・復元も行っていないため、再生成後は新しいWebViewが作られ、startKiosk()からloadUrl()でページが読み込み直されます。つまり再生成前の
PermissionRequestは、対応するページがもう存在しないので、応答しても意味がありません。deny()せずに保持し続けても宙に浮くだけです。ユーザー体験としても破綻しません。
Activity再生成、deny()、ページ再読み込みActivityResultLauncherのコールバックは復元された registry 経由で新インスタンスに届くが、pendingPermissionRequestは null なので何もしないisCameraGranted()が true になっているため、権限ダイアログ無しで即座に許可される結果として、回転を挟んでもページはカメラを使えます。
なお
android:configChangesを指定して回転時の再生成自体を避ける手もありますが、それはキオスク表示の挙動を変える別の判断になるため、本PRの範囲外としています。