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)