diff --git a/.idea/dictionaries/davidallison.xml b/.idea/dictionaries/davidallison.xml
index c3804ce27bfb..833a85d06aa6 100644
--- a/.idea/dictionaries/davidallison.xml
+++ b/.idea/dictionaries/davidallison.xml
@@ -17,6 +17,7 @@
Notetypes
Pictogrammers
RTRIGGER
+ Roborazzi
SPDX
Spdx
SuperMemo
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
index 505c2d809373..a9d4e3e49c6e 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
@@ -30,16 +30,20 @@ import android.content.Intent
import android.content.SharedPreferences
import android.content.res.Configuration
import android.database.SQLException
+import android.graphics.Color
import android.graphics.PixelFormat
+import android.os.Build
import android.os.Bundle
import android.text.util.Linkify
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.View
-import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
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.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
@@ -60,8 +64,13 @@ import androidx.core.util.component1
import androidx.core.util.component2
import androidx.core.view.MenuItemCompat
import androidx.core.view.OnReceiveContentListener
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat.Type.displayCutout
+import androidx.core.view.WindowInsetsCompat.Type.navigationBars
+import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.doOnLayout
import androidx.core.view.isVisible
+import androidx.core.view.updatePadding
import androidx.draganddrop.DropHelper
import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.commit
@@ -93,9 +102,11 @@ import com.ichi2.anki.analytics.UsageAnalytics
import com.ichi2.anki.android.back.exitViaDoubleTapBackCallback
import com.ichi2.anki.android.input.ShortcutGroup
import com.ichi2.anki.android.input.shortcut
+import com.ichi2.anki.android.view.locationInWindow
import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.common.crashreporting.CrashReportService
import com.ichi2.anki.common.time.TimeManager
+import com.ichi2.anki.common.utils.android.isRobolectric
import com.ichi2.anki.common.utils.annotation.KotlinCleanup
import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat
import com.ichi2.anki.contextmenu.DeckPickerMenuContentProvider
@@ -161,6 +172,7 @@ import com.ichi2.anki.snackbar.SnackbarBuilder
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.sync.MeteredSyncPolicy
import com.ichi2.anki.sync.launchCatchingRequiringOneWaySyncDiscardUndo
+import com.ichi2.anki.ui.BottomFadeFrameLayout
import com.ichi2.anki.ui.ResizablePaneManager
import com.ichi2.anki.ui.animations.fadeIn
import com.ichi2.anki.ui.animations.fadeOut
@@ -253,7 +265,9 @@ open class DeckPicker :
@VisibleForTesting
internal val deckPickerBinding: IncludeDeckPickerBinding
get() = binding.deckPickerPane
- private val floatingActionButtonBinding: IncludeFloatingAddButtonBinding
+
+ @VisibleForTesting
+ val floatingActionButtonBinding: IncludeFloatingAddButtonBinding
get() = deckPickerBinding.floatingActionButton
override var fragmented: Boolean
@@ -464,6 +478,11 @@ open class DeckPicker :
return
}
+ // match the status bar theme of the rest of the app
+ enableEdgeToEdge(
+ statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
+ navigationBarStyle = BottomFadeFrameLayout.navigationBarStyle(),
+ )
// Then set theme and content view
super.onCreate(savedInstanceState)
@@ -497,6 +516,7 @@ open class DeckPicker :
// create inherited navigation drawer layout here so that it can be used by parent class
initNavigationDrawer()
+ applyEdgeToEdgeInsets()
title = resources.getString(R.string.app_name)
deckPickerBinding.deckPickerContent.visibility = View.GONE
@@ -599,6 +619,61 @@ open class DeckPicker :
super.setupBackPressedCallbacks()
}
+ override fun fitsSystemWindows(): Boolean = false
+
+ private fun applyEdgeToEdgeInsets() {
+ fun setRecyclerViewBottomPaddingAbove(target: View) {
+ val recyclerView = deckPickerBinding.decks
+ if (recyclerView.height == 0 || target.height == 0) return
+ val bottom = recyclerView.locationInWindow().y + recyclerView.height
+ val topOfTarget = (target.layoutParams as? MarginLayoutParams)?.topMargin ?: 0
+ recyclerView.updatePadding(
+ bottom = (bottom - target.locationInWindow().y - topOfTarget).coerceAtLeast(0),
+ )
+ }
+
+ deckPickerBinding.decksFadeWrapper.setup(window)
+ deckPickerBinding.decksFadeWrapper.anchorView = deckPickerBinding.reviewSummaryTextView
+ ViewCompat.setOnApplyWindowInsetsListener(binding.toolbarContainer) { toolbar, insets ->
+ val bars = insets.getInsets(systemBars() or displayCutout())
+ toolbar.updatePadding(left = bars.left, top = bars.top, right = bars.right)
+ insets
+ }
+ // Bottom padding is used. wrap_content meant margin wasn't viable
+ ViewCompat.setOnApplyWindowInsetsListener(deckPickerBinding.root) { deckPickerInclude, insets ->
+ val bars = insets.getInsets(systemBars() or displayCutout())
+ // BottomFadeFrameLayout handles contrast for bottom nav; let the system draw
+ // its own scrim when the nav bar is on the side (no manual fade applies there)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val nav = insets.getInsets(navigationBars())
+ window.isNavigationBarContrastEnforced = nav.left > 0 || nav.right > 0
+ }
+ deckPickerInclude.updatePadding(
+ left = bars.left,
+ right = if (fragmented) 0 else bars.right,
+ )
+ deckPickerBinding.reviewSummaryTextView.updatePadding(bottom = bars.bottom)
+
+ // hack for Roborazzi screenshot tests
+ val fabBottomOffset = if (isRobolectric) 12.dp.toPx(this) else -12.dp.toPx(this)
+ floatingActionButtonBinding.root.updatePadding(bottom = bars.bottom + fabBottomOffset)
+
+ setRecyclerViewBottomPaddingAbove(floatingActionButtonBinding.fabMain)
+ insets
+ }
+ floatingActionButtonBinding.fabMain.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
+ setRecyclerViewBottomPaddingAbove(v)
+ }
+ if (fragmented) {
+ val studyoptionsView = binding.studyoptionsFragment ?: return
+ ViewCompat.setOnApplyWindowInsetsListener(studyoptionsView) { studyOptions, insets ->
+ val bars = insets.getInsets(systemBars() or displayCutout())
+ studyOptions.updatePadding(right = bars.right, bottom = bars.bottom)
+ insets
+ }
+ }
+ }
+
@Suppress("UNUSED_PARAMETER")
private fun setupFlows() {
fun onDeckDeleted(result: DeckDeletionResult) {
@@ -648,7 +723,7 @@ open class DeckPicker :
deckPickerBinding.reviewSummaryTextView.text = studiedToday
// Adjust bottom margin of fabLinearLayout based on reviewSummaryTextView height
deckPickerBinding.reviewSummaryTextView.doOnLayout { view ->
- val layoutParams = floatingActionButtonBinding.fabLinearLayout.layoutParams as ViewGroup.MarginLayoutParams
+ val layoutParams = floatingActionButtonBinding.fabLinearLayout.layoutParams as MarginLayoutParams
layoutParams.setMargins(0, 0, 0, view.height / 2)
floatingActionButtonBinding.fabLinearLayout.layoutParams = layoutParams
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/android/view/ViewLocation.kt b/AnkiDroid/src/main/java/com/ichi2/anki/android/view/ViewLocation.kt
new file mode 100644
index 000000000000..04ea982350bf
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/android/view/ViewLocation.kt
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.android.view
+
+import android.view.View
+
+/** Wrapper around the `IntArray` returned by [View.getLocationOnScreen]. */
+@JvmInline
+value class ViewLocation(
+ val data: IntArray,
+) {
+ val x: Int get() = data[0]
+ val y: Int get() = data[1]
+}
+
+/** The receiver's position on the device screen. */
+fun View.locationOnScreen(scratch: IntArray = IntArray(2)): ViewLocation {
+ getLocationOnScreen(scratch)
+ return ViewLocation(scratch)
+}
+
+/** The receiver's position relative to its window. */
+fun View.locationInWindow(scratch: IntArray = IntArray(2)): ViewLocation {
+ getLocationInWindow(scratch)
+ return ViewLocation(scratch)
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/BottomFadeFrameLayout.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/BottomFadeFrameLayout.kt
new file mode 100644
index 000000000000..217f1a6d512c
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/BottomFadeFrameLayout.kt
@@ -0,0 +1,106 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.ui
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.os.Build
+import android.util.AttributeSet
+import android.view.View
+import android.view.Window
+import android.widget.FrameLayout
+import androidx.activity.SystemBarStyle
+import com.ichi2.anki.android.view.locationOnScreen
+import com.ichi2.anki.compat.setDstOutBlendCompat
+import com.ichi2.themes.Themes
+
+/**
+ * A container which emulates the 'fade' effect which is applied to content when underneath the
+ * button navigation.
+ *
+ * Some Android versions have a transparent nav, which applied a transparency filter to elements
+ * underneath it.
+ * Different Android versions implement this differently, and the colors were non-public, this
+ * class applies this effect in a standardized manner.
+ *
+ * @see anchorView
+ *
+ * BUG: This applies transparency twice on API <= 25 - not a huge deal.
+ */
+class BottomFadeFrameLayout
+ @JvmOverloads
+ constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+ ) : FrameLayout(context, attrs, defStyleAttr) {
+ /** buffer used by [View.screenY] */
+ private val locBuf = IntArray(2)
+
+ /** The fade applies to this view, and all views below it vertically */
+ var anchorView: View? = null
+ set(value) {
+ if (field !== value) {
+ field = value
+ invalidate()
+ }
+ }
+
+ private val paint =
+ Paint().apply {
+ color = FADE_ALPHA shl 24
+ setDstOutBlendCompat()
+ }
+
+ override fun dispatchDraw(canvas: Canvas) {
+ val target = anchorView
+ if (target == null || target.height == 0 || width == 0 || height == 0) {
+ super.dispatchDraw(canvas)
+ return
+ }
+ val targetTopInSelf = target.screenY - screenY
+ val bandTop = (targetTopInSelf + target.paddingTop).toFloat()
+ val bandBottom = height.toFloat()
+ if (bandBottom <= bandTop) {
+ super.dispatchDraw(canvas)
+ return
+ }
+ // Draw children into a scratch buffer so the fade only affects them
+ val saveCount = canvas.saveLayer(0f, 0f, width.toFloat(), height.toFloat(), null)
+ super.dispatchDraw(canvas)
+ canvas.drawRect(0f, bandTop, width.toFloat(), bandBottom, paint)
+ canvas.restoreToCount(saveCount)
+ }
+
+ private inline val View.screenY: Int
+ get() = locationOnScreen(locBuf).y
+
+ /** Configures [window] for use with this view */
+ fun setup(window: Window) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ window.isNavigationBarContrastEnforced = false
+ }
+ }
+
+ companion object {
+ /** Alpha removed from each child pixel inside the fade. */
+ // 90% - standard Android, so "Studied in" isn't obscured when overlapping a deck name
+ const val FADE_ALPHA: Int = 0xE6
+
+ /** `navigationBarStyle` to pass to [androidx.activity.enableEdgeToEdge] */
+ fun navigationBarStyle(): SystemBarStyle =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ SystemBarStyle.auto(
+ lightScrim = Color.TRANSPARENT,
+ darkScrim = Color.TRANSPARENT,
+ ) { Themes.isNightTheme }
+ } else {
+ // Dark nav bar icons are not supported by the platform
+ // Maintain a dark nav, rather than just using fade
+ // androidx.activity.EdgeToEdge.DefaultDarkScrim
+ SystemBarStyle.dark(Color.argb(0x80, 0x1b, 0x1b, 0x1b))
+ }
+ }
+ }
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">
-
+
+
+
+
-
+
+
+
+
-
+ android:layout_height="match_parent">
+
+
+
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesScreenshotTest.kt
index d9587d99d33c..125b10f551ba 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesScreenshotTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesScreenshotTest.kt
@@ -130,7 +130,8 @@ class AllActivitiesScreenshotTest : ScreenshotTest() {
*
* WARN: Does not match reality. There are issues with element placement and scrolling lists. [FAB in Deck Picker]
*/
-private fun Activity.simulateEdgeToEdge() {
+// TODO: Move to testFixtures after #20989
+fun Activity.simulateEdgeToEdge() {
val insets =
WindowInsetsCompat
.Builder()
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerScreenshotTest.kt
index bf46c6b1a934..050bfd052f2f 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerScreenshotTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerScreenshotTest.kt
@@ -1,17 +1,25 @@
-// SPDX-FileCopyrightText: 2026 Brayan Oliveira
// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: 2026 Brayan Oliveira
+
package com.ichi2.anki
import androidx.core.content.edit
import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.testutils.BackupManagerTestUtilities
+import org.junit.After
import org.junit.Before
import org.junit.Test
+/**
+ * Screenshot tests for [DeckPicker]
+ *
+ * `./gradlew :AnkiDroid:verifyRoborazziPlayDebug -Pscreenshot --tests "com.ichi2.anki.DeckPickerScreenshotTest"`
+ */
class DeckPickerScreenshotTest : ScreenshotTest() {
@Before
override fun setUp() {
super.setUp()
+ setPhoneQualifiers()
ensureCollectionLoadIsSynchronous()
setIntroductionSlidesShown(true)
BackupManagerTestUtilities.setupSpaceForBackup(targetContext)
@@ -19,13 +27,24 @@ class DeckPickerScreenshotTest : ScreenshotTest() {
targetContext.sharedPrefs().edit { putBoolean("backupPromptDisabled", true) }
}
+ @After
+ fun tearDownBackup() {
+ BackupManagerTestUtilities.reset()
+ }
+
@Test
- fun baseState_and_fabExpanded() {
- val intent = DeckPicker.getIntent(targetContext)
- val activity = startActivityNormallyOpenCollectionWithIntent(DeckPicker::class.java, intent)
- captureScreen("baseState")
+ fun baseState_and_fabExpanded() =
+ withDeckPicker(deckCount = 0) { deckPicker ->
+ captureScreen("baseState")
- activity.floatingActionMenu.showFloatingActionMenu()
- captureScreen("fabExpanded")
- }
+ deckPicker.floatingActionMenu.showFloatingActionMenu()
+ captureScreen("fabExpanded")
+ }
+
+ @Test
+ fun edgeToEdge_30_decks() =
+ withDeckPicker(deckCount = 30) { deckPicker ->
+ deckPicker.simulateEdgeToEdge()
+ captureScreen("edgeToEdge_30_decks")
+ }
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt
new file mode 100644
index 000000000000..2247e83466ea
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import com.ichi2.testutils.BackupManagerTestUtilities
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * Screenshot tests for [DeckPicker] in fragmented mode.
+ *
+ * `./gradlew :AnkiDroid:verifyRoborazziPlayDebug -Pscreenshot --tests "com.ichi2.anki.DeckPickerTabletScreenshotTest"`
+ */
+class DeckPickerTabletScreenshotTest : ScreenshotTest() {
+ @Before
+ override fun setUp() {
+ super.setUp()
+ setTabletQualifiers()
+ }
+
+ @After
+ fun tearDownBackup() {
+ BackupManagerTestUtilities.reset()
+ }
+
+ @Test
+ fun deckPickerWith30Decks() =
+ withDeckPicker(deckCount = 30, withCards = true) { deckPicker ->
+ deckPicker.simulateEdgeToEdge()
+ // Allow tryShowStudyOptionsPanel()'s async fragment commit to finalize
+ // before capturing, otherwise the right pane is empty.
+ advanceRobolectricLooper()
+ advanceRobolectricLooper()
+ captureScreen("30_decks_tablet")
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTestFixture.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTestFixture.kt
new file mode 100644
index 000000000000..00991975ea84
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTestFixture.kt
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import android.content.Intent
+import androidx.core.content.edit
+import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooper
+import com.ichi2.anki.preferences.sharedPrefs
+import com.ichi2.testutils.BackupManagerTestUtilities
+
+// TODO: move to testFixtures once RobolectricTest is moved
+
+context(test: RobolectricTest)
+fun withDeckPicker(
+ deckCount: Int,
+ withCards: Boolean = false,
+ block: (DeckPicker) -> Unit,
+) {
+ // startup code occurs here so all users of this method are correctly setup
+ test.ensureCollectionLoadIsSynchronous()
+ test.setIntroductionSlidesShown(true)
+ BackupManagerTestUtilities.setupSpaceForBackup(test.targetContext)
+ // suppress the periodic 'backup your collection' prompt so the screenshot is just the deck list
+ test.targetContext.sharedPrefs().edit { putBoolean("backupPromptDisabled", true) }
+ if (withCards) test.ensureNonEmptyCollection()
+ for (i in 0 until deckCount) {
+ // 'Deck' is before 'Default' alphabetically
+ test.addDeck("Test Deck $i")
+ }
+ val deckPicker =
+ test.startActivityNormallyOpenCollectionWithIntent(DeckPicker::class.java, Intent()).also {
+ advanceRobolectricLooper() // may be a fix for flaky tests
+ }
+ block(deckPicker)
+}
diff --git a/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt b/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt
index 8262169b7b34..cf00c5842080 100644
--- a/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt
+++ b/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt
@@ -22,6 +22,9 @@ import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.graphics.Bitmap
+import android.graphics.Paint
+import android.graphics.PorterDuff
+import android.graphics.PorterDuffXfermode
import android.media.MediaRecorder
import android.media.ThumbnailUtils
import android.net.Uri
@@ -292,6 +295,10 @@ open class BaseCompat : Compat {
context: Context,
defaultValue: Boolean,
): Boolean = false
+
+ override fun setDstOutBlend(paint: Paint) {
+ paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
+ }
}
typealias CompatV24 = BaseCompat
diff --git a/compat/src/main/java/com/ichi2/anki/compat/Compat.kt b/compat/src/main/java/com/ichi2/anki/compat/Compat.kt
index 6923c71632b2..c027b24511c5 100644
--- a/compat/src/main/java/com/ichi2/anki/compat/Compat.kt
+++ b/compat/src/main/java/com/ichi2/anki/compat/Compat.kt
@@ -25,6 +25,8 @@ import android.content.pm.PackageManager.NameNotFoundException
import android.content.pm.ResolveInfo
import android.graphics.Bitmap
import android.graphics.Bitmap.CompressFormat
+import android.graphics.BlendMode
+import android.graphics.Paint
import android.media.MediaRecorder
import android.net.Uri
import android.os.Bundle
@@ -288,4 +290,7 @@ interface Compat {
context: Context,
defaultValue: Boolean = false,
): Boolean
+
+ /** Configures [paint] to use [BlendMode.DST_OUT]. */
+ fun setDstOutBlend(paint: Paint)
}
diff --git a/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt b/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt
index 6ae88076b6ea..a4132d8937ea 100644
--- a/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt
+++ b/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt
@@ -24,6 +24,8 @@ import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.PackageManager.NameNotFoundException
import android.content.pm.ResolveInfo
+import android.graphics.BlendMode
+import android.graphics.Paint
import android.os.Build
import android.os.Bundle
import android.view.KeyCharacterMap.deviceHasKey
@@ -222,3 +224,6 @@ inline fun Bundle.requireSerializableCompat(key: Stri
requireNotNull(compat.getSerializable(this, key, T::class.java)) {
"key: '$key' not found or null"
}
+
+/** Configures this [Paint] to use [BlendMode.DST_OUT]. */
+fun Paint.setDstOutBlendCompat() = compat.setDstOutBlend(this)
diff --git a/compat/src/main/java/com/ichi2/anki/compat/CompatV29.kt b/compat/src/main/java/com/ichi2/anki/compat/CompatV29.kt
index b678e8b0c85f..96922c22ac16 100644
--- a/compat/src/main/java/com/ichi2/anki/compat/CompatV29.kt
+++ b/compat/src/main/java/com/ichi2/anki/compat/CompatV29.kt
@@ -18,6 +18,8 @@ package com.ichi2.anki.compat
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
+import android.graphics.BlendMode
+import android.graphics.Paint
import android.media.ThumbnailUtils
import android.net.Uri
import android.os.Environment
@@ -93,6 +95,10 @@ open class CompatV29 : CompatV26() {
return Settings.Secure.getInt(context.contentResolver, "navigation_mode", defaultMode) == 2
}
+ override fun setDstOutBlend(paint: Paint) {
+ paint.blendMode = BlendMode.DST_OUT
+ }
+
companion object {
// obtained from AOSP source
private val THUMBNAIL_MINI_KIND = Size(512, 384)