Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 54 additions & 13 deletions app/src/main/java/io/doany/lockview/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() {

Expand All @@ -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 =
Expand All @@ -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) {
Expand Down Expand Up @@ -260,6 +299,8 @@ class MainActivity : AppCompatActivity() {
}

override fun onDestroy() {
pendingPermissionRequest?.deny()

Copy link
Copy Markdown

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() 済みとして応答されてしまいます(新インスタンスの pendingPermissionRequestnull のため、後から届く requestCameraPermission のコールバックは何もせず無視されます)。

キオスク端末で画面回転が実質発生しない運用であれば実害は小さいですが、AndroidManifest.xml に向きの固定(android:screenOrientation など)が見当たらないため、回転可能な端末では「許可したのに使えない」という体験になり得ます。設定変更による再生成と本当にアプリを離れる場合(isFinishing/isChangingConfigurations)を区別するか、pendingPermissionRequest を引き継ぐ対応が要るかもしれません。

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

設定変更で onDestroy() が走り deny() されるという指摘は、そのとおりです。ただ実害は無いと判断し、この点は変更していません。

理由は、同じ再生成によって PermissionRequest の送り主であるページ自体が失われるためです。

AndroidManifest.xmlMainActivityandroid:configChanges の指定が無いため、回転時は Activity が破棄・再生成されます。WebViewactivity_main.xml から findViewById で取得している通常のビューで、状態の保存・復元も行っていないため、再生成後は新しい WebView が作られ、startKiosk() から loadUrl() でページが読み込み直されます。

つまり再生成前の PermissionRequest は、対応するページがもう存在しないので、応答しても意味がありません。deny() せずに保持し続けても宙に浮くだけです。

ユーザー体験としても破綻しません。

  1. 回転前: ページがカメラを要求 → 権限ダイアログ表示
  2. 回転 → Activity 再生成、deny()、ページ再読み込み
  3. ユーザーが「許可」を選択 → ActivityResultLauncher のコールバックは復元された registry 経由で新インスタンスに届くが、pendingPermissionRequest は null なので何もしない
  4. 読み込み直されたページが再度カメラを要求 → isCameraGranted() が true になっているため、権限ダイアログ無しで即座に許可される

結果として、回転を挟んでもページはカメラを使えます。

なお android:configChanges を指定して回転時の再生成自体を避ける手もありますが、それはキオスク表示の挙動を変える別の判断になるため、本PRの範囲外としています。

pendingPermissionRequest = null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

要確認(前回レビューの再掲・未対応): onDestroy() は画面回転などの設定変更による再生成時にも呼ばれます。AndroidManifest.xmlMainActivity の向き固定 (android:screenOrientation) も configChanges の指定も見当たらないため、回転可能な端末では設定変更のたびに再生成が起こり得ます。

その場合、システムのカメラ権限ダイアログ自体は継続表示されますが、pendingPermissionRequest?.deny() によりページには既に「拒否」として応答済みになります。ユーザーがダイアログで「許可」を選んでも、新しいインスタンスの pendingPermissionRequestnull のため requestCameraPermission のコールバックは何もせず無視され、結果的に「許可したのにカメラが使えない」という体験になります。

今回のコミット (008c99a) で launch() の重複呼び出しは解消されましたが、この設定変更時の経路は今回のPRの範囲では対応されていないようです。キオスク運用で回転が発生しないなら実害は小さいですが、isFinishing/isChangingConfigurations で区別する、または onRetainNonConfigurationInstance 等で pendingPermissionRequest を引き継ぐ対応の検討をお勧めします。

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この点は前回のスレッド(#17 (comment) )で回答済みです。意図的に変更していません。

要点だけ再掲します。同じ再生成によって PermissionRequest の送り主であるページ自体が失われるため、応答しても意味がありません。

  • MainActivityandroid:configChanges の指定が無いので回転時は再生成される
  • WebView は状態の保存・復元をしておらず、再生成後は startKiosk()loadUrl() でページが読み込み直される
  • したがって再生成前の PermissionRequest は対象を失っており、deny() せず保持しても宙に浮くだけ

ユーザー体験も破綻しません。読み込み直されたページが再度カメラを要求した時点で isCameraGranted() が true になっているため、権限ダイアログ無しで即座に許可されます。

unlockDialog?.dismiss()
unlockDialog = null
super.onDestroy()
Expand Down
38 changes: 38 additions & 0 deletions app/src/main/java/io/doany/lockview/kiosk/WebPermissionPolicy.kt
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)
}
2 changes: 1 addition & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<resources>
<string name="app_name">Lockview</string>
<string name="camera_permission_required">許可されないとアプリが実行できません</string>
<string name="camera_permission_denied">カメラが許可されなかったため、ページのカメラ機能は使用できません</string>

<string name="accessibility_service_label">Lockview キオスクモード</string>
<string name="accessibility_service_description">ステータスバーとナビゲーションバーの操作を無効化してキオスクモードを維持します。</string>
Expand Down
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()))
}
}