diff --git a/app/src/main/java/io/doany/lockview/MainActivity.kt b/app/src/main/java/io/doany/lockview/MainActivity.kt index 9c27d6e..791e010 100644 --- a/app/src/main/java/io/doany/lockview/MainActivity.kt +++ b/app/src/main/java/io/doany/lockview/MainActivity.kt @@ -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,21 +70,15 @@ 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 @@ -87,6 +86,46 @@ class MainActivity : AppCompatActivity() { 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 unlockDialog?.dismiss() unlockDialog = null super.onDestroy() diff --git a/app/src/main/java/io/doany/lockview/kiosk/WebPermissionPolicy.kt b/app/src/main/java/io/doany/lockview/kiosk/WebPermissionPolicy.kt new file mode 100644 index 0000000..e17e34b --- /dev/null +++ b/app/src/main/java/io/doany/lockview/kiosk/WebPermissionPolicy.kt @@ -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, isCameraGranted: Boolean): Array { + if (!isCameraGranted) { + return emptyArray() + } + return requested.filter { it == RESOURCE_VIDEO_CAPTURE }.toTypedArray() + } + + /** + * 要求にカメラの利用が含まれるか。 + * + * 含まれない要求は許可する余地が無いので、カメラ権限を要求する必要もない。 + */ + fun requestsCamera(requested: Array): Boolean = + requested.contains(RESOURCE_VIDEO_CAPTURE) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09967e1..4b77292 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ Lockview - 許可されないとアプリが実行できません + カメラが許可されなかったため、ページのカメラ機能は使用できません Lockview キオスクモード ステータスバーとナビゲーションバーの操作を無効化してキオスクモードを維持します。 diff --git a/app/src/test/java/io/doany/lockview/kiosk/WebPermissionPolicyTest.kt b/app/src/test/java/io/doany/lockview/kiosk/WebPermissionPolicyTest.kt new file mode 100644 index 0000000..82d189e --- /dev/null +++ b/app/src/test/java/io/doany/lockview/kiosk/WebPermissionPolicyTest.kt @@ -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(), + WebPermissionPolicy.allowedResources(arrayOf(video), isCameraGranted = false) + ) + } + + /** 宣言していない権限は、カメラ権限があっても許可しない。 */ + @Test + fun neverAllowsUndeclaredResources() { + assertArrayEquals( + emptyArray(), + 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(), + 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())) + } +}