From 0cbffdf65e063c783096ceaa85aa7441afbb0bd2 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 4 May 2026 07:52:41 +0100 Subject: [PATCH 1/5] fix(ui): remove 'launch_screen' background For activities with 'Theme_Dark.Launcher', android:windowBackground is set, but was never removed. On views with transparent backgrounds (such as the Card Browser column headers), this leaked through when edge to edge was enabled Part of issue 17334 - Edge to Edge Assisted-by: Claude Opus 4.7 - diagnostics + fix --- .../src/main/java/com/ichi2/themes/Themes.kt | 23 +++++++++++++++++++ .../layout-sw600dp/activity_card_browser.xml | 1 - 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/themes/Themes.kt b/AnkiDroid/src/main/java/com/ichi2/themes/Themes.kt index e326a9bafaba..1662053ce029 100644 --- a/AnkiDroid/src/main/java/com/ichi2/themes/Themes.kt +++ b/AnkiDroid/src/main/java/com/ichi2/themes/Themes.kt @@ -18,11 +18,14 @@ package com.ichi2.themes +import android.app.Activity import android.content.Context import android.content.res.Configuration import android.graphics.Color +import android.util.TypedValue import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatDelegate +import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.withStyledAttributes import androidx.core.graphics.drawable.toDrawable import androidx.core.view.WindowInsetsControllerCompat @@ -34,6 +37,7 @@ import com.ichi2.anki.settings.enums.AppTheme import com.ichi2.anki.settings.enums.DayTheme import com.ichi2.anki.settings.enums.NightTheme import com.ichi2.anki.settings.enums.Theme +import com.ichi2.themes.Themes.currentTheme /** * Helper methods to configure things related to AnkiDroid's themes @@ -50,6 +54,25 @@ object Themes { context.setTheme(currentTheme.styleResId) } + fun setTheme(activity: Activity) { + val tv = TypedValue() + activity.theme.resolveAttribute(android.R.attr.windowBackground, tv, true) + val hadLauncherSplash = tv.resourceId == R.drawable.launch_screen + + setTheme(activity as Context) + + if (hadLauncherSplash) { + activity.theme.resolveAttribute(android.R.attr.windowBackground, tv, true) + val replacement = + if (tv.type in TypedValue.TYPE_FIRST_COLOR_INT..TypedValue.TYPE_LAST_COLOR_INT) { + tv.data.toDrawable() + } else { + AppCompatResources.getDrawable(activity, tv.resourceId) + } + activity.window.setBackgroundDrawable(replacement) + } + } + fun setLegacyActionBar(context: Context) { context.setTheme(R.style.ThemeOverlay_LegacyActionBar) } diff --git a/AnkiDroid/src/main/res/layout-sw600dp/activity_card_browser.xml b/AnkiDroid/src/main/res/layout-sw600dp/activity_card_browser.xml index d461daaa92ce..4a4525f0f83e 100644 --- a/AnkiDroid/src/main/res/layout-sw600dp/activity_card_browser.xml +++ b/AnkiDroid/src/main/res/layout-sw600dp/activity_card_browser.xml @@ -11,7 +11,6 @@ android:id="@+id/card_browser_xl_view" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="?android:attr/colorBackground" android:orientation="horizontal"> Date: Mon, 4 May 2026 08:04:36 +0100 Subject: [PATCH 2/5] refactor: remove legacy action bar unused --- AnkiDroid/src/main/java/com/ichi2/themes/Themes.kt | 4 ---- AnkiDroid/src/main/res/values/legacy_action_bar.xml | 7 ------- 2 files changed, 11 deletions(-) delete mode 100644 AnkiDroid/src/main/res/values/legacy_action_bar.xml diff --git a/AnkiDroid/src/main/java/com/ichi2/themes/Themes.kt b/AnkiDroid/src/main/java/com/ichi2/themes/Themes.kt index 1662053ce029..d49dd3c974d6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/themes/Themes.kt +++ b/AnkiDroid/src/main/java/com/ichi2/themes/Themes.kt @@ -73,10 +73,6 @@ object Themes { } } - fun setLegacyActionBar(context: Context) { - context.setTheme(R.style.ThemeOverlay_LegacyActionBar) - } - /** * Updates [currentTheme] value based on preferences. * If `Follow system` is selected, it's updated to the theme set diff --git a/AnkiDroid/src/main/res/values/legacy_action_bar.xml b/AnkiDroid/src/main/res/values/legacy_action_bar.xml deleted file mode 100644 index c3c97d162f6d..000000000000 --- a/AnkiDroid/src/main/res/values/legacy_action_bar.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file From 25129b4b4fb32e342b536b89b933415fd0e8ff90 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 4 May 2026 08:13:51 +0100 Subject: [PATCH 3/5] test(card-browser): screenshot test Roborazzi test added to catch future regressions Issue 17334 - edge to edge Assisted-by: Claude Opus 4.7 - unit tests --- .../ichi2/anki/CardBrowserScreenshotTest.kt | 74 +++++++++++++++++++ .../java/com/ichi2/anki/CardBrowserTest.kt | 47 +++++++----- .../java/com/ichi2/anki/ScreenshotTest.kt | 6 ++ 3 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserScreenshotTest.kt diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserScreenshotTest.kt new file mode 100644 index 000000000000..c4e207f8c38b --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserScreenshotTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package com.ichi2.anki + +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Screenshot tests for [CardBrowser] + * + * `./gradlew :AnkiDroid:verifyRoborazziPlayDebug -Pscreenshot --tests "com.ichi2.anki.CardBrowserScreenshotTest"` + */ +@RunWith(AndroidJUnit4::class) +class CardBrowserScreenshotTest : ScreenshotTest() { + init { + setPhoneQualifiers() + } + + @Test + fun cardBrowserWith30Notes() = + withCardBrowser(noteCount = 50) { browser -> + // Robolectric reports zero system-bar insets by default. Inject realistic ones + // so the app's edge-to-edge layout responds as it would on a real device. + val density = browser.resources.displayMetrics.density + val statusBarPx = (24 * density).toInt() + val navBarPx = (48 * density).toInt() + val insets = + WindowInsetsCompat + .Builder() + .setInsets(WindowInsetsCompat.Type.statusBars(), Insets.of(0, statusBarPx, 0, 0)) + .setInsets(WindowInsetsCompat.Type.navigationBars(), Insets.of(0, 0, 0, navBarPx)) + .build() + ViewCompat.dispatchApplyWindowInsets(browser.window.decorView, insets) + + // overlay a translucent band where the nav bar would sit + // to see if content is drawn underneath it + val decor = browser.window.decorView as ViewGroup + val navBarOverlay = + View(browser).apply { + setBackgroundColor(0x80000000.toInt()) + } + decor.addView( + navBarOverlay, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + navBarPx, + Gravity.BOTTOM, + ), + ) + + captureScreen("30_notes") + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt index 3ef7c9be6ef8..1c059ec88c03 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt @@ -974,26 +974,6 @@ class CardBrowserTest : RobolectricTest() { advanceRobolectricLooper() } - /** Returns an instance of [CardBrowser] containing [noteCount] notes */ - private fun getBrowserWithNotes( - noteCount: Int, - reversed: Boolean = false, - ): CardBrowser { - ensureCollectionLoadIsSynchronous() - if (reversed) { - for (i in 0 until noteCount) { - addBasicAndReversedNote(i.toString(), "back") - } - } else { - for (i in 0 until noteCount) { - addBasicNote(i.toString(), "back") - } - } - return super.startRegularActivity(Intent()).also { - advanceRobolectricLooper() // may be a fix for flaky tests - } - } - private val browserWithNoNewCards: CardBrowser get() = getBrowserWithNotes(0) @@ -2132,3 +2112,30 @@ suspend fun CardBrowser.selectAll() { val CardBrowser.menu: Menu get() = if (this.useSearchView) cardBrowserFragment.searchBar!!.menu else shadowOf(this).optionsMenu!! + +/** Returns an instance of [CardBrowser] containing [noteCount] notes */ +context(test: RobolectricTest) +fun getBrowserWithNotes( + noteCount: Int, + reversed: Boolean = false, +): CardBrowser { + test.ensureCollectionLoadIsSynchronous() + if (reversed) { + for (i in 0 until noteCount) { + test.addBasicAndReversedNote(i.toString(), "back") + } + } else { + for (i in 0 until noteCount) { + test.addBasicNote(i.toString(), "back") + } + } + return test.startRegularActivity(Intent()).also { + advanceRobolectricLooper() // may be a fix for flaky tests + } +} + +context(test: RobolectricTest) +fun withCardBrowser( + noteCount: Int, + block: (CardBrowser) -> Unit, +) = block(getBrowserWithNotes(noteCount)) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt index f9879e791edf..253136d4202b 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt @@ -21,6 +21,7 @@ import com.github.takahirom.roborazzi.captureScreenRoboImage import com.github.takahirom.roborazzi.provideRoborazziContext import org.junit.experimental.categories.Category import org.robolectric.annotation.GraphicsMode +import org.robolectric.RuntimeEnvironment import java.io.File interface ScreenshotTestCategory @@ -31,6 +32,11 @@ interface ScreenshotTestCategory @Category(ScreenshotTestCategory::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) abstract class ScreenshotTest : RobolectricTest() { + /** Pixel-class phone in portrait, light theme. */ + protected fun setPhoneQualifiers() { + RuntimeEnvironment.setQualifiers("w411dp-h914dp-notnight-420dpi") + } + /** * Captures a screenshot to `build/outputs/roborazzi//.png`. * From 8a70ac3da73f850b82f9f0654006bd9ea7edfd89 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 4 May 2026 08:13:51 +0100 Subject: [PATCH 4/5] feat(card-browser): enable edge to edge A wrapper was required for the toolbar: adding padding increased the height from minHeight, so apply the padding to a parent element. Roborazzi test added to catch future regressions Issue 17334 Assisted-by: Claude Opus 4.7 - unit tests + frame fix, rest was my own --- .idea/dictionaries/android.xml | 1 + .../main/java/com/ichi2/anki/CardBrowser.kt | 23 ++++++ .../ichi2/anki/browser/CardBrowserFragment.kt | 16 ++++ .../res/layout/include_browser_toolbar.xml | 78 ++++++++++--------- 4 files changed, 82 insertions(+), 36 deletions(-) diff --git a/.idea/dictionaries/android.xml b/.idea/dictionaries/android.xml index 3e99df561dfe..e92a82e184a2 100644 --- a/.idea/dictionaries/android.xml +++ b/.idea/dictionaries/android.xml @@ -9,6 +9,7 @@ ENOENT FILESIZE ONEPLUS + Roborazzi allempty apkgfileprovider asynctasks diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt index 7a8267a8225a..8b5f0c24841f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt @@ -20,6 +20,7 @@ package com.ichi2.anki import android.content.Context import android.content.Intent +import android.graphics.Color import android.os.Bundle import android.view.KeyEvent import android.view.Menu @@ -30,6 +31,8 @@ import android.view.WindowManager import android.widget.LinearLayout import android.widget.TextView import androidx.activity.OnBackPressedCallback +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.annotation.CheckResult @@ -38,7 +41,10 @@ import androidx.annotation.MainThread import androidx.annotation.VisibleForTesting import androidx.core.view.MenuHost import androidx.core.view.MenuProvider +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible +import androidx.core.view.updatePadding import androidx.fragment.app.commit import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -298,6 +304,8 @@ open class CardBrowser : return } tagsDialogFactory = TagsDialogFactory(this).attachToActivity(this) + // match the status bar theme of the rest of the app + enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT)) super.onCreate(savedInstanceState) binding = ActivityCardBrowserBinding.inflate(layoutInflater) if (!ensureStoragePermissions()) { @@ -312,6 +320,7 @@ open class CardBrowser : setViewBinding(binding) initNavigationDrawer(findViewById(android.R.id.content)) + applyToolbarInsets() /** * Check if noteEditorFrame is not null and if its visibility is set to VISIBLE. @@ -477,6 +486,20 @@ open class CardBrowser : super.setupBackPressedCallbacks() } + override fun fitsSystemWindows(): Boolean = false + + private fun applyToolbarInsets() { + val container = findViewById(R.id.toolbar_container) ?: return + ViewCompat.setOnApplyWindowInsetsListener(container) { view, insets -> + val bars = + insets.getInsets( + WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.displayCutout(), + ) + view.updatePadding(left = bars.left, top = bars.top, right = bars.right) + insets + } + } + private fun showSaveChangesDialog(launcher: NoteEditorLauncher) { DiscardChangesDialog.showDialog( context = this, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt index 1fe5741aa6f4..3bfd48c3acb8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -48,6 +48,7 @@ import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible +import androidx.core.view.updatePadding import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction @@ -252,7 +253,9 @@ class CardBrowserFragment : cardsListView = view.findViewById(R.id.card_browser_list).apply { attachFastScroller(R.id.browser_scroller) + clipToPadding = false } + applyContentInsets(view) DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL).apply { setDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.browser_divider)!!) cardsListView.addItemDecoration(this) @@ -372,6 +375,19 @@ class CardBrowserFragment : setupMenu() } + private fun applyContentInsets(root: View) { + ViewCompat.setOnApplyWindowInsetsListener(root) { v, insets -> + val bars = + insets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), + ) + v.updatePadding(left = bars.left, right = bars.right) + // RecyclerView uses clipToPadding=false so list scrolls under the navigation bar + cardsListView.updatePadding(bottom = bars.bottom) + insets + } + } + private fun setupMenu() { val menuHost: MenuHost = requireCardBrowserActivity() diff --git a/AnkiDroid/src/main/res/layout/include_browser_toolbar.xml b/AnkiDroid/src/main/res/layout/include_browser_toolbar.xml index 7d7336152ad0..0bc23e65090d 100644 --- a/AnkiDroid/src/main/res/layout/include_browser_toolbar.xml +++ b/AnkiDroid/src/main/res/layout/include_browser_toolbar.xml @@ -1,50 +1,56 @@ - + android:background="?attr/appBarColor"> - - - + android:minHeight="?attr/actionBarSize" + android:theme="@style/ActionBarStyle" + app:navigationContentDescription="@string/abc_action_bar_up_description" + app:navigationIcon="?attr/homeAsUpIndicator"> + android:textSize="20sp" + android:visibility="gone" /> - - - + android:background="?attr/selectableItemBackground" + android:orientation="vertical"> + + + + + + + From 725a33e237f5b471afe440407d502870cd08ade4 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 4 May 2026 08:33:57 +0100 Subject: [PATCH 5/5] refactor(card-browser): remove xml ripple Added at bind time in BrowserMultiColumnAdapter.kt Assisted-by: Claude Opus 4.7 - diagnostic --- AnkiDroid/src/main/res/layout/item_card_browser.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/AnkiDroid/src/main/res/layout/item_card_browser.xml b/AnkiDroid/src/main/res/layout/item_card_browser.xml index 1d70b58080fb..9c003506e304 100644 --- a/AnkiDroid/src/main/res/layout/item_card_browser.xml +++ b/AnkiDroid/src/main/res/layout/item_card_browser.xml @@ -3,7 +3,6 @@ android:id="@+id/card_item_browser" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="?attr/selectableItemBackground" xmlns:tools="http://schemas.android.com/tools">