Keks
September 20, 2026, 10:58am
1
AnkiDroid Version: 2.25.0beta1
Android 14 (UP1A.231005.007)
Device Model: POCO X4 GT
Fullscreen mode “Hide the system bars and answer buttons”
New study screen off.
Issue: Tapping the screen triggers an action lower than where I actually tap (touch offset).
Workaround: If I change the “Fullscreen mode” setting from “Hide the system bars and answer buttons” to any other option, the issue disappears.
Additional details: Prior to this update, the status bar area was dark. Now it appears light, similar to how it looks on the New study screen. This visual change might be related to the touch offset issue.
David
September 20, 2026, 3:49pm
2
David
September 20, 2026, 6:49pm
3
Should be solved in beta2. Cheers!
main ← david-allison:21937
opened 05:22PM - 20 Sep 26 UTC
> [!NOTE]
> Assisted-by: GPT-6
## Fixes
* Fixes #21937
## Approach
Modi… fy the margins to match the WebView.
## How Has This Been Tested?
This felt too long to check in to the repo
```patch
Index: AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTouchTest.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTouchTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTouchTest.kt
new file mode 100644
--- /dev/null (revision 94923211d581c09e093825763f9c99cc118ce5a8)
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTouchTest.kt (revision 94923211d581c09e093825763f9c99cc118ce5a8)
@@ -0,0 +1,249 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import android.annotation.SuppressLint
+import android.content.pm.ActivityInfo
+import android.content.res.Configuration
+import android.graphics.Rect
+import android.os.Build
+import android.view.View
+import android.webkit.WebView
+import androidx.core.content.edit
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat.Type.systemBars
+import androidx.core.view.isGone
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.UiSelector
+import com.ichi2.anki.cardviewer.GestureProcessor
+import com.ichi2.anki.common.preferences.sharedPrefs
+import com.ichi2.anki.reviewer.FullScreenMode
+import com.ichi2.anki.tests.InstrumentedTest
+import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission
+import com.ichi2.anki.testutil.ensureWebViewIsSupported
+import com.ichi2.anki.testutil.grantPermissions
+import com.ichi2.anki.testutil.notificationPermission
+import com.ichi2.anki.testutil.waitUntil
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.json.JSONArray
+import org.junit.Assume.assumeTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.UUID
+import kotlin.math.roundToInt
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
+class ReviewerTouchTest : InstrumentedTest() {
+ @get:Rule
+ val runtimePermissionRule = grantPermissions(storagePermission, notificationPermission)
+
+ /** Issue 21937: fullscreen taps activated card content below the visible target. */
+ @Test
+ fun fullscreenTapsActivateVisibleLinksInPortrait() = checkFullscreenTaps(landscape = false)
+
+ @Test
+ fun fullscreenTapsActivateVisibleLinksInLandscape() = checkFullscreenTaps(landscape = true)
+
+ private fun checkFullscreenTaps(landscape: Boolean) {
+ assumeTrue("This test changes the emulator's display cutout", isEmulator())
+ ensureWebViewIsSupported()
+ withDisplayCutout {
+ withFullscreenCard {
+ ActivityScenario.launch(Reviewer::class.java).use { scenario ->
+ // Limit the orientation change to this activity, leaving the device's rotation settings alone.
+ scenario.onActivity {
+ it.requestedOrientation =
+ if (landscape) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ }
+ val webView = scenario.awaitFullscreenCard(landscape)
+ // Android's first-use fullscreen confirmation consumes taps until dismissed.
+ val fullscreenConfirmation = device.findObject(UiSelector().resourceId("android:id/ok"))
+ if (fullscreenConfirmation.waitForExists(3000)) fullscreenConfirmation.click()
+ device.waitForIdle()
+ webView.awaitRender()
+
+ val expectedClicks = mutableListOf<String>()
+ for (target in targets) {
+ webView.tapVisibleLink(target)
+ var clicks = ""
+ waitUntil(message = { "Tapping $target did not activate a link; clicks = $clicks" }) {
+ clicks = webView.evaluateJavascript("window.clicked")
+ clicks != JSONArray(expectedClicks).toString()
+ }
+ expectedClicks.add(target)
+ assertEquals(JSONArray(expectedClicks).toString(), clicks, "Each tap must activate only the visible link")
+ }
+ }
+ }
+ }
+ }
+
+ private val device get() = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+ @SuppressLint("DiscouragedApi") // Simple cmd overlay commands, without input or shell syntax.
+ private fun withDisplayCutout(block: () -> Unit) {
+ // Use Android's cutout emulation so this also reproduces on CI devices without a notch.
+ val cutoutOverlay = "com.android.internal.display.cutout.emulation.tall"
+ val cutoutWasEnabled = device.executeShellCommand("cmd overlay list --user current android").contains("[x] $cutoutOverlay")
+ try {
+ device.executeShellCommand("cmd overlay enable --user current $cutoutOverlay")
+ device.waitForIdle()
+ block()
+ } finally {
+ if (!cutoutWasEnabled) device.executeShellCommand("cmd overlay disable --user current $cutoutOverlay")
+ }
+ }
+
+ private fun withFullscreenCard(block: () -> Unit) {
+ val prefs = testContext.sharedPrefs()
+ val previousFullscreenMode = prefs.getString(FullScreenMode.PREF_KEY, null)
+ val previousGestures = prefs.all[GestureProcessor.PREF_KEY] as? Boolean
+ val previousDeck = col.decks.selected()
+ val deckId = col.decks.addNormalDeckWithName("ReviewerTouchTest-${UUID.randomUUID()}").id
+ try {
+ FullScreenMode.setPreference(prefs, FullScreenMode.FULLSCREEN_ALL_GONE)
+ prefs.edit { putBoolean(GestureProcessor.PREF_KEY, true) }
+ col.decks.select(deckId)
+ addNoteUsingBasicNoteType(clickableCard, "Back").firstCard(col).update { did = deckId }
+
+ block()
+ } finally {
+ prefs.edit {
+ putString(FullScreenMode.PREF_KEY, previousFullscreenMode)
+ if (previousGestures == null) remove(GestureProcessor.PREF_KEY) else putBoolean(GestureProcessor.PREF_KEY, previousGestures)
+ }
+ col.decks.remove(listOf(deckId))
+ col.decks.select(previousDeck)
+ }
+ }
+
+ private fun ActivityScenario<Reviewer>.awaitFullscreenCard(landscape: Boolean): WebView {
+ lateinit var webView: WebView
+ waitUntil(30.seconds, message = { "The fullscreen reviewer did not settle with a display cutout (landscape = $landscape)" }) {
+ var ready = false
+ onActivity { reviewer ->
+ val insets = ViewCompat.getRootWindowInsets(reviewer.window.decorView)
+ val cutout = insets?.displayCutout
+ val cutoutInset =
+ if (landscape) {
+ maxOf(cutout?.safeInsetLeft ?: 0, cutout?.safeInsetRight ?: 0)
+ } else {
+ cutout?.safeInsetTop ?: 0
+ }
+ val orientation = if (landscape) Configuration.ORIENTATION_LANDSCAPE else Configuration.ORIENTATION_PORTRAIT
+ ready =
+ reviewer.webView != null &&
+ reviewer.resources.configuration.orientation == orientation &&
+ insets != null && !insets.isVisible(systemBars()) && cutoutInset > 0 &&
+ reviewer.findViewById<View>(R.id.answer_options_layout).isGone &&
+ reviewer.findViewById<View>(R.id.toolbar_container).isGone
+ reviewer.webView?.let { webView = it }
+ }
+ ready
+ }
+ waitUntil(30.seconds, message = { "The card's links did not load" }) {
+ webView.evaluateJavascript(
+ "Array.isArray(window.clicked) && document.querySelectorAll('#targets a').length === ${targets.size}",
+ ) ==
+ "true"
+ }
+ return webView
+ }
+
+ private fun WebView.awaitRender() {
+ val rendered = CompletableDeferred<Unit>()
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ postVisualStateCallback(
+ 0,
+ object : WebView.VisualStateCallback() {
+ override fun onComplete(requestId: Long) {
+ rendered.complete(Unit)
+ }
+ },
+ )
+ }
+ runBlocking { withTimeout(10.seconds) { rendered.await() } }
+ }
+
+ private fun WebView.tapVisibleLink(target: String) {
+ // Read the rendered DOM position, then inject a real screen tap through the
+ // activity and its touch overlay. Calling element.click() would miss this bug.
+ val coordinates =
+ JSONArray(
+ evaluateJavascript(
+ """
+ (() => {
+ const rect = document.getElementById('$target').getBoundingClientRect();
+ return [(rect.left + rect.width / 2) * devicePixelRatio,
+ (rect.top + rect.height / 2) * devicePixelRatio];
+ })()
+ """.trimIndent(),
+ ),
+ )
+ val origin = IntArray(2)
+ val visibleBounds = Rect()
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ getLocationOnScreen(origin)
+ getGlobalVisibleRect(visibleBounds)
+ }
+ val x = origin[0] + coordinates.getDouble(0).roundToInt()
+ val y = origin[1] + coordinates.getDouble(1).roundToInt()
+ assertTrue(visibleBounds.contains(x, y), "$target at ($x, $y) must be visible in $visibleBounds")
+ assertTrue(device.click(x, y), "Could not inject a tap on $target at ($x, $y)")
+ }
+
+ private fun WebView.evaluateJavascript(script: String): String {
+ val result = CompletableDeferred<String>()
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ evaluateJavascript(script) { result.complete(it) }
+ }
+ return runBlocking { withTimeout(10.seconds) { result.await() } }
+ }
+
+ private val targets =
+ listOf(
+ "top-left",
+ "top-center",
+ "top-right",
+ "middle-left",
+ "center",
+ "middle-right",
+ "bottom-left",
+ "bottom-center",
+ "bottom-right",
+ )
+
+ private val clickableTargets =
+ targets
+ .mapIndexed { index, target ->
+ """<a id="$target" href="#" onclick="window.clicked.push(this.id); return false;">$index</a>"""
+ }.joinToString("\n")
+
+ // Keep the links smaller than the emulated cutout so an offset tap cannot still hit its target.
+ private val clickableCard =
+ """
+ <script>window.clicked = [];</script>
+ <style>
+ #targets {
+ position: fixed; inset: 48px; display: grid;
+ grid-template-columns: repeat(3, 24px); grid-template-rows: repeat(3, 24px);
+ justify-content: space-between; align-content: space-between;
+ }
+ #targets a { width: 24px; height: 24px; line-height: 24px; font-size: 16px; }
+ </style>
+ <div id="targets">
+ $clickableTargets
+ </div>
+ """.trimIndent()
+}
```
## Checklist
- [x] You have a descriptive commit message with a short title (first line, max 50 chars).
- [x] You have commented your code, particularly in hard-to-understand areas
- [x] You have performed a self-review of your own code
- [ ] UI changes: include screenshots of all affected screens (in particular showing any new or changed strings)
- [ ] UI Changes: You have tested your change using the [Google Accessibility Scanner](https://play.google.com/store/apps/details?id=com.google.android.apps.accessibility.auditor)