From c3b7ff22098b04508cd137f58afc010f5b7dccba Mon Sep 17 00:00:00 2001 From: Ruk Doe Date: Wed, 16 Sep 2026 12:47:50 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E3=82=AB=E3=83=A1=E3=83=A9=E6=A8=A9?= =?UTF-8?q?=E9=99=90=E3=81=AE=E8=A6=81=E6=B1=82=E3=81=A8WebView=E3=81=AE?= =?UTF-8?q?=E6=A8=A9=E9=99=90=E8=A8=B1=E5=8F=AF=E3=82=92=E8=A6=8B=E7=9B=B4?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 起動時に無条件でカメラ権限を要求しており、拒否されると continueStartup() が 呼ばれないまま WebView が読み込まれず、復帰経路も無いため空白画面で固まっていた。 カメラを使わないページを表示するだけのキオスクでも権限が必須になっていた。 ページが実際にカメラを要求した時点で初めて権限を求めるよう変更する。 あわせて onPermissionRequest が request.grant(request.resources) で要求内容を問わず 許可していたのを、宣言済みのカメラのみを、権限が付与されている場合に限って 許可するよう変更する。判定は Android 非依存の WebPermissionPolicy に切り出した。 Co-Authored-By: Claude Opus 5 (1M context) --- .../java/io/doany/lockview/MainActivity.kt | 63 ++++++++++++---- .../lockview/kiosk/WebPermissionPolicy.kt | 38 ++++++++++ app/src/main/res/values/strings.xml | 2 +- .../lockview/kiosk/WebPermissionPolicyTest.kt | 73 +++++++++++++++++++ 4 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/io/doany/lockview/kiosk/WebPermissionPolicy.kt create mode 100644 app/src/test/java/io/doany/lockview/kiosk/WebPermissionPolicyTest.kt diff --git a/app/src/main/java/io/doany/lockview/MainActivity.kt b/app/src/main/java/io/doany/lockview/MainActivity.kt index 9c27d6e..f9889c4 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,42 @@ 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 + } + // 応答待ちの要求が残っている場合は、古い方を取り下げて新しい方だけを扱う。 + pendingPermissionRequest?.deny() + 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 +295,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())) + } +} From 008c99a596320b0794e5db112c85d46ff1ff8ce0 Mon Sep 17 00:00:00 2001 From: Ruk Doe Date: Wed, 16 Sep 2026 12:53:02 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E6=A8=A9=E9=99=90=E3=83=80=E3=82=A4?= =?UTF-8?q?=E3=82=A2=E3=83=AD=E3=82=B0=E3=81=8C=E9=87=8D=E3=81=AA=E3=82=8B?= =?UTF-8?q?=E7=B5=8C=E8=B7=AF=E3=82=92=E3=81=AA=E3=81=8F=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 応答待ちの要求を deny() しつつ同じ launcher へ launch() を重ねて呼ぶ形になっており、 システムダイアログ表示中の再 launch が安全か確証が無かった。 応答待ちの間は新しい要求をその場で取り下げ、launch を重ねない形にする。 Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/java/io/doany/lockview/MainActivity.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/doany/lockview/MainActivity.kt b/app/src/main/java/io/doany/lockview/MainActivity.kt index f9889c4..791e010 100644 --- a/app/src/main/java/io/doany/lockview/MainActivity.kt +++ b/app/src/main/java/io/doany/lockview/MainActivity.kt @@ -102,8 +102,12 @@ class MainActivity : AppCompatActivity() { request.deny() return } - // 応答待ちの要求が残っている場合は、古い方を取り下げて新しい方だけを扱う。 - pendingPermissionRequest?.deny() + // 権限ダイアログを重ねて出さないよう、応答待ちの間は新しい要求を取り下げる。 + // 拒否されたページは、必要であれば改めて要求してくる。 + if (pendingPermissionRequest != null) { + request.deny() + return + } pendingPermissionRequest = request requestCameraPermission.launch(Manifest.permission.CAMERA) }