diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 5cc23374b99b..da19efe45cd5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -52,6 +52,7 @@ 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 import androidx.core.view.WindowInsetsCompat.Type.displayCutout import androidx.core.view.WindowInsetsCompat.Type.navigationBars import androidx.core.view.WindowInsetsCompat.Type.systemBars @@ -705,7 +706,9 @@ open class DeckPicker : ViewCompat.setOnApplyWindowInsetsListener(studyoptionsView) { studyOptions, insets -> val bars = insets.getInsets(systemBars() or displayCutout()) studyOptions.updatePadding(right = bars.right, bottom = bars.bottom) - insets + // insets are applied by padding. CONSUMED means hosted fragments don't apply them + // again (e.g. ScheduleRemindersFragment). + WindowInsetsCompat.CONSUMED } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsActivity.kt index 376d4c4d706c..6d9de04e5783 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsActivity.kt @@ -156,7 +156,9 @@ class StudyOptionsActivity : WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), ) view.updatePadding(left = bars.left, right = bars.right, bottom = bars.bottom) - insets + // insets are applied by padding. CONSUMED means hosted fragments don't apply them + // again (e.g. ScheduleRemindersFragment). + WindowInsetsCompat.CONSUMED } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/Insets.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/Insets.kt index 2f7b6fcf2954..ba59b0e9323d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/Insets.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/Insets.kt @@ -3,8 +3,14 @@ package com.ichi2.anki.utils import android.view.View +import androidx.core.graphics.Insets import androidx.core.view.RoundedCornerCompat +import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.marginBottom +import androidx.core.view.marginLeft +import androidx.core.view.marginRight +import androidx.core.view.marginTop /** * The radius, in pixels, of the larger of the two bottom corner radii. @@ -34,3 +40,32 @@ fun WindowInsetsCompat.bottomCornerClearance(view: View): Int { // arc), so the content never dips into the corner. return (bottomRoundedCornerRadius - endInset).coerceAtLeast(0) } + +/** + * A view's padding and margins from when [doOnApplyWindowInsets] was called. + */ +class InitialSpacing( + val padding: Insets, + val margins: Insets, +) + +/** + * Sets a window insets listener which is also supplied with the view's [InitialSpacing]. + * + * Inset handlers usually add an inset to a padding or margin from the layout file. Deriving the + * new value from the view compounds it when the insets are dispatched more than once, so [block] + * is given the values from when the listener was set and sets absolute values based on them. + * + * The insets are returned unconsumed, so sibling views also receive them. + */ +fun View.doOnApplyWindowInsets(block: (view: View, insets: WindowInsetsCompat, initial: InitialSpacing) -> Unit) { + val initial = + InitialSpacing( + padding = Insets.of(paddingLeft, paddingTop, paddingRight, paddingBottom), + margins = Insets.of(marginLeft, marginTop, marginRight, marginBottom), + ) + ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> + block(view, insets, initial) + insets + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/ViewUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/ViewUtils.kt index da06541b894a..0b3daeb42253 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/ViewUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/ViewUtils.kt @@ -146,6 +146,8 @@ value class Dp( ) { // TODO: improve once we have context parameters fun toPx(context: Context) = dp.dpToPx(context) + + operator fun plus(other: Dp) = Dp(dp = dp + other.dp) } private fun Float.dpToPx(context: Context): Int = (this * context.resources.displayMetrics.density + 0.5f).toInt() diff --git a/AnkiDroid/src/main/res/layout/fragment_reminder_troubleshooting.xml b/AnkiDroid/src/main/res/layout/fragment_reminder_troubleshooting.xml index 0886954d8216..947b73ce05db 100644 --- a/AnkiDroid/src/main/res/layout/fragment_reminder_troubleshooting.xml +++ b/AnkiDroid/src/main/res/layout/fragment_reminder_troubleshooting.xml @@ -17,6 +17,7 @@ app:title="Troubleshooting" /> diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewRemindersDatabaseTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewRemindersDatabaseTest.kt index 444a16443c7c..938e87e3d489 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewRemindersDatabaseTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewRemindersDatabaseTest.kt @@ -22,6 +22,7 @@ import com.ichi2.anki.RobolectricTest import com.ichi2.anki.common.time.MockTime import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.libanki.EpochMilliseconds +import com.ichi2.anki.settings.Prefs import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.MapSerializer @@ -112,13 +113,18 @@ class ReviewRemindersDatabaseTest : RobolectricTest() { @Before override fun setUp() { super.setUp() - ReviewRemindersDatabase.remindersSharedPrefs.edit { clear() } + clearRemindersState() } @After override fun tearDown() { super.tearDown() + clearRemindersState() + } + + private fun clearRemindersState() { ReviewRemindersDatabase.remindersSharedPrefs.edit { clear() } + Prefs.reviewReminderDeserializationErrors = "" } @Test diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewRemindersInsetsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewRemindersInsetsTest.kt new file mode 100644 index 000000000000..bb6ac5277b1a --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewRemindersInsetsTest.kt @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.reviewreminders + +import android.app.Activity +import android.view.View +import androidx.annotation.IdRes +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type.displayCutout +import androidx.core.view.WindowInsetsCompat.Type.navigationBars +import androidx.core.view.WindowInsetsCompat.Type.statusBars +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.commit +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.ichi2.anki.DeckPicker +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.StudyOptionsActivity +import com.ichi2.anki.common.destinations.StudyOptionsDestination +import com.ichi2.anki.common.destinations.launchActivity +import com.ichi2.anki.databinding.FragmentReminderTroubleshootingBinding +import com.ichi2.anki.databinding.FragmentScheduleRemindersBinding +import com.ichi2.anki.preferences.PreferencesActivity +import com.ichi2.anki.preferences.PreferencesFragment +import com.ichi2.anki.reviewreminders.ScheduleRemindersFragment.FragmentHost +import com.ichi2.anki.utils.ConfigAwareSingleFragmentActivity +import com.ichi2.anki.withDeckPicker +import com.ichi2.testutils.BackupManagerTestUtilities +import com.ichi2.testutils.insetsOf +import com.ichi2.utils.Dp +import com.ichi2.utils.dp +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RuntimeEnvironment + +/** + * Edge-to-edge inset handling for [ScheduleRemindersFragment] and [ReminderTroubleshootingFragment] + * across their [FragmentHost]s. + * + * The hosts fall into two groups: + * + * - hosts where the fragment fills the window ([FragmentHost.SETTINGS] and + * [FragmentHost.STANDALONE_ACTIVITY]): the fragment applies the system bar insets itself; + * - hosts which apply the insets to the fragment's container ([FragmentHost.STUDY_OPTIONS_FRAME] + * and [FragmentHost.STUDY_OPTIONS_FRAGMENT]): the fragment must not apply them again. + */ +@RunWith(AndroidJUnit4::class) +class ReviewRemindersInsetsTest : RobolectricTest() { + /** The height of the simulated status bar */ + private val statusBarHeight = 24.dp + + /** The size of the simulated navigation bar: its height, or its width when on the side of the screen */ + private val navigationBarSize = 48.dp + + @Test + fun `standalone host - toolbar content clears the status bar and cutout`() = + withStandaloneScheduleReminders { activity, binding -> + activity.dispatchInsets(cutoutLeft = 32.dp) + + assertThat( + "toolbar content is pushed clear of the status bar", + binding.appbar.paddingTop, + equalTo(statusBarHeight.toPx(targetContext)), + ) + assertThat( + "toolbar content clears the cutout", + binding.appbar.paddingLeft, + equalTo(32.dp.toPx(targetContext)), + ) + } + + @Test + fun `standalone host - troubleshooting content clears the system bars`() = + withStandaloneTroubleshooting { activity, binding -> + activity.dispatchInsets(navBarBottom = navigationBarSize) + + assertThat( + "content is pushed clear of the status bar", + binding.root.paddingTop, + equalTo(statusBarHeight.toPx(targetContext)), + ) + assertThat( + "content clears the navigation bar", + binding.root.paddingBottom, + equalTo(navigationBarSize.toPx(targetContext)), + ) + } + + @Test + fun `settings host - collapsible toolbar clears the status bar`() = + withSettingsScheduleReminders { activity, binding -> + activity.dispatchInsets() + + assertThat( + "the root does not pad itself; the app bar handles the inset", + binding.root.paddingTop, + equalTo(0), + ) + assertThat( + "the collapsible toolbar is pushed clear of the status bar", + binding.toolbar.top, + equalTo(statusBarHeight.toPx(targetContext)), + ) + } + + @Test + fun `study options frame host - insets are applied by the host, not the fragment`() = + withStudyOptionsFrameScheduleReminders { activity, binding -> + activity.dispatchInsets(navBarBottom = navigationBarSize) + + assertThat( + "the host clears the navigation bar", + (binding.root.parent as View).paddingBottom, + equalTo(navigationBarSize.toPx(targetContext)), + ) + assertThat( + "the fragment does not apply the top inset again", + binding.root.paddingTop, + equalTo(0), + ) + assertThat( + "the fragment does not apply the bottom inset again", + binding.root.paddingBottom, + equalTo(0), + ) + assertThat( + "the toolbar is provided by the host", + binding.appbar.isVisible, + equalTo(false), + ) + } + + @Test + fun `study options frame host - troubleshooting does not apply insets of its own`() = + withStudyOptionsFrameTroubleshooting { activity, binding -> + activity.dispatchInsets(navBarBottom = navigationBarSize) + + assertThat( + "the fragment does not apply the top inset again", + binding.root.paddingTop, + equalTo(0), + ) + assertThat( + "the fragment does not apply the bottom inset again", + binding.root.paddingBottom, + equalTo(0), + ) + } + + @Test + fun `study options fragment host - side panel toolbar is not offset by the status bar`() = + withStudyOptionsFragmentScheduleReminders { deckPicker, binding -> + deckPicker.dispatchInsets(navBarBottom = navigationBarSize) + + assertThat( + "the host clears the navigation bar", + (binding.root.parent as View).paddingBottom, + equalTo(navigationBarSize.toPx(targetContext)), + ) + // The side panel sits below the DeckPicker toolbar, which already clears the status + // bar: the panel's own toolbar must not absorb the status bar inset again + assertThat( + "the panel toolbar is not padded by the status bar", + binding.appbar.paddingTop, + equalTo(0), + ) + assertThat( + "the panel toolbar is not pushed down by the status bar", + binding.nonCollapsibleToolbar.top, + equalTo(0), + ) + } + + /** + * Dispatches realistic system-bar insets, which Robolectric otherwise reports as zero. + * + * Dispatches at `android.R.id.content` rather than at the decor: for windows which have not + * (yet) opted into edge-to-edge, Robolectric's decor and AppCompat's sub-decor consume the + * insets before they reach the activity's views. Edge-to-edge devices deliver the insets to + * the views, which is the behavior under test. + */ + private fun Activity.dispatchInsets( + navBarBottom: Dp = 0.dp, + navBarRight: Dp = 0.dp, + cutoutLeft: Dp = 0.dp, + ) { + val insets = + with(targetContext) { + WindowInsetsCompat + .Builder() + .setInsets(statusBars(), insetsOf(top = statusBarHeight)) + .setInsets(navigationBars(), insetsOf(right = navBarRight, bottom = navBarBottom)) + .setInsets(displayCutout(), insetsOf(left = cutoutLeft)) + .build() + } + ViewCompat.dispatchApplyWindowInsets(findViewById(android.R.id.content), insets) + // relayout synchronously so the insets affect view positions before the test asserts + val decor = window.decorView + decor.measure( + View.MeasureSpec.makeMeasureSpec(decor.width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(decor.height, View.MeasureSpec.EXACTLY), + ) + decor.layout(0, 0, decor.width, decor.height) + } + + /** Launches [ScheduleRemindersFragment] in its standalone activity */ + private fun withStandaloneScheduleReminders(block: (ConfigAwareSingleFragmentActivity, FragmentScheduleRemindersBinding) -> Unit) { + val intent = ScheduleRemindersFragment.getIntent(targetContext, ReviewReminderScope.Global) + ActivityScenario.launch(intent).use { scenario -> + advanceRobolectricLooper() + scenario.onActivity { activity -> + block(activity, FragmentScheduleRemindersBinding.bind(activity.fragment!!.requireView())) + } + } + } + + /** Launches [ReminderTroubleshootingFragment] in the standalone activity */ + private fun withStandaloneTroubleshooting(block: (ConfigAwareSingleFragmentActivity, FragmentReminderTroubleshootingBinding) -> Unit) = + withStandaloneScheduleReminders { activity, _ -> + val view = + activity.supportFragmentManager.showFragment( + R.id.fragment_container, + ReminderTroubleshootingFragment.newInstance(FragmentHost.STANDALONE_ACTIVITY), + ) + block(activity, FragmentReminderTroubleshootingBinding.bind(view)) + } + + /** Launches [ScheduleRemindersFragment] hosted in the settings screen */ + private fun withSettingsScheduleReminders(block: (PreferencesActivity, FragmentScheduleRemindersBinding) -> Unit) { + ActivityScenario.launch(PreferencesActivity.getIntent(targetContext)).use { scenario -> + scenario.onActivity { activity -> + val fm = (activity.fragment as PreferencesFragment).childFragmentManager + val view = + fm.showFragment( + R.id.settings_container, + ScheduleRemindersFragment.newInstance(ReviewReminderScope.Global, FragmentHost.SETTINGS), + ) + block(activity, FragmentScheduleRemindersBinding.bind(view)) + } + } + } + + /** Launches [ScheduleRemindersFragment] hosted in the study options frame */ + private fun withStudyOptionsFrameScheduleReminders(block: (StudyOptionsActivity, FragmentScheduleRemindersBinding) -> Unit) { + val deckId = addDeck("Test Deck") + launchActivity(StudyOptionsDestination).use { scenario -> + scenario.onActivity { activity -> + val view = + activity.supportFragmentManager.showFragment( + R.id.studyoptions_frame, + ScheduleRemindersFragment.newInstance( + ReviewReminderScope.DeckSpecific(deckId), + FragmentHost.STUDY_OPTIONS_FRAME, + ), + ) + block(activity, FragmentScheduleRemindersBinding.bind(view)) + } + } + } + + /** Launches [ReminderTroubleshootingFragment] hosted in the study options frame */ + private fun withStudyOptionsFrameTroubleshooting(block: (StudyOptionsActivity, FragmentReminderTroubleshootingBinding) -> Unit) { + launchActivity(StudyOptionsDestination).use { scenario -> + scenario.onActivity { activity -> + val view = + activity.supportFragmentManager.showFragment( + R.id.studyoptions_frame, + ReminderTroubleshootingFragment.newInstance(FragmentHost.STUDY_OPTIONS_FRAME), + ) + block(activity, FragmentReminderTroubleshootingBinding.bind(view)) + } + } + } + + /** Launches [ScheduleRemindersFragment] in the deck picker's tablet side panel */ + private fun withStudyOptionsFragmentScheduleReminders(block: (DeckPicker, FragmentScheduleRemindersBinding) -> Unit) { + // the side panel only exists on wide screens + RuntimeEnvironment.setQualifiers(RobolectricDeviceQualifiers.MediumTablet) + withDeckPicker(deckCount = 1, withCards = true) { deckPicker -> + val deckId = addDeck("Panel Deck") + val view = + deckPicker.supportFragmentManager.showFragment( + R.id.studyoptions_fragment, + ScheduleRemindersFragment.newInstance( + ReviewReminderScope.DeckSpecific(deckId), + FragmentHost.STUDY_OPTIONS_FRAGMENT, + ), + ) + block(deckPicker, FragmentScheduleRemindersBinding.bind(view)) + } + BackupManagerTestUtilities.reset() + } + + /** Replaces the contents of [containerId] with [fragment] and returns the fragment's laid-out view */ + private fun FragmentManager.showFragment( + @IdRes containerId: Int, + fragment: Fragment, + ): View { + commit { replace(containerId, fragment) } + advanceRobolectricLooper() + return findFragmentById(containerId)!!.requireView() + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewRemindersScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewRemindersScreenshotTest.kt index 895a1807989e..c913dd3cc9ed 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewRemindersScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReviewRemindersScreenshotTest.kt @@ -13,13 +13,19 @@ import com.ichi2.anki.ScreenshotTest import com.ichi2.anki.StudyOptionsActivity import com.ichi2.anki.common.destinations.StudyOptionsDestination import com.ichi2.anki.common.destinations.launchActivity +import com.ichi2.anki.databinding.FragmentReminderTroubleshootingBinding +import com.ichi2.anki.databinding.FragmentScheduleRemindersBinding import com.ichi2.anki.preferences.PreferencesActivity import com.ichi2.anki.preferences.PreferencesFragment import com.ichi2.anki.reviewreminders.ScheduleRemindersFragment.FragmentHost import com.ichi2.anki.utils.ConfigAwareSingleFragmentActivity import com.ichi2.anki.withDeckPicker import com.ichi2.testutils.BackupManagerTestUtilities +import com.ichi2.testutils.simulateSystemBars +import com.ichi2.utils.dp +import kotlinx.coroutines.runBlocking import org.junit.Test +import org.robolectric.RuntimeEnvironment /** * Covers all [FragmentHost] configurations of the fragment. @@ -41,32 +47,46 @@ class ReviewRemindersScreenshotTest : ScreenshotTest() { prefix: String, captureScrolled: Boolean = true, ) { - ActivityScenario.launch(PreferencesActivity.getIntent(targetContext)).use { scenario -> - scenario.onActivity { activity -> - val fm = (activity.fragment as PreferencesFragment).childFragmentManager - commitScheduleRemindersAndCapture( - fragmentManager = fm, - containerId = R.id.settings_container, - host = FragmentHost.SETTINGS, - scope = ReviewReminderScope.Global, - prefix = prefix, - ) - if (captureScrolled) { - fm - .findFragmentById(R.id.settings_container) - ?.view - ?.findViewById(R.id.appbar) - ?.setExpanded(false, false) - advanceRobolectricLooper() - captureScreen("${prefix}_scheduleReminders_scrolled") - } - commitTroubleshootingAndCapture( - fragmentManager = fm, - containerId = R.id.settings_container, - host = FragmentHost.SETTINGS, - prefix = prefix, - ) + withSettingsScheduleReminders { _, fm -> + captureScreen("${prefix}_scheduleReminders") + if (captureScrolled) { + fm.collapseToolbar() + captureScreen("${prefix}_scheduleReminders_scrolled") } + commitTroubleshootingAndCapture( + fragmentManager = fm, + containerId = R.id.settings_container, + host = FragmentHost.SETTINGS, + prefix = prefix, + ) + } + } + + @Test + fun `settings host with a landscape display cutout`() { + insertReminders(count = 6) + RuntimeEnvironment.setQualifiers("+land") + withSettingsScheduleReminders { activity, fm -> + activity.simulateSystemBars(cutoutLeft = 32.dp) + captureScreen("settingsHost_landscapeCutout") + + // collapsed: the content scrim must extend behind the cutout band while the + // toolbar content stays clear of it + fm.collapseToolbar() + captureScreen("settingsHost_landscapeCutout_collapsed") + } + } + + @Test + fun `settings host with a landscape display cutout - RTL`() { + insertReminders(count = 6) + RuntimeEnvironment.setQualifiers("+ar") + RuntimeEnvironment.setQualifiers("+land") + withSettingsScheduleReminders { activity, _ -> + // the cutout is physically on the left; the expanded title starts at the right + // in RTL, so it is inset via its end margin + activity.simulateSystemBars(cutoutLeft = 32.dp) + captureScreen("settingsHost_landscapeCutout_rtl") } } @@ -115,17 +135,70 @@ class ReviewRemindersScreenshotTest : ScreenshotTest() { } @Test - fun `standalone activity host`() { - val intent = ScheduleRemindersFragment.getIntent(targetContext, ReviewReminderScope.Global) - ActivityScenario.launch(intent).use { scenario -> + fun `standalone activity host`() = + withStandaloneScheduleReminders { activity -> + captureScreen("standaloneActivityHost_scheduleReminders") + commitTroubleshootingAndCapture( + fragmentManager = activity.supportFragmentManager, + containerId = R.id.fragment_container, + host = FragmentHost.STANDALONE_ACTIVITY, + prefix = "standaloneActivityHost", + ) + } + + @Test + fun `standalone activity host with system bars`() = + withStandaloneScheduleReminders { activity -> + activity.simulateSystemBars() + captureScreen("standaloneActivityHost_systemBars") + } + + @Test + fun `standalone activity host with system bars and a scrollable list`() { + insertReminders(count = 12) + withStandaloneScheduleReminders { activity -> + activity.simulateSystemBars() + val binding = FragmentScheduleRemindersBinding.bind(activity.fragment!!.requireView()) + // scrolled to the end: the last reminder must clear the navigation bar band + binding.recyclerView.scrollToPosition(binding.recyclerView.adapter!!.itemCount - 1) advanceRobolectricLooper() - scenario.onActivity { activity -> - captureScreen("standaloneActivityHost_scheduleReminders") - commitTroubleshootingAndCapture( - fragmentManager = activity.supportFragmentManager, - containerId = R.id.fragment_container, - host = FragmentHost.STANDALONE_ACTIVITY, - prefix = "standaloneActivityHost", + captureScreen("standaloneActivityHost_systemBars_scrolledToEnd") + } + } + + @Test + fun `standalone activity host troubleshooting with system bars`() { + // landscape: the checks overflow the screen, so the end of the content must scroll + // clear of the navigation bar band + RuntimeEnvironment.setQualifiers("+land") + withStandaloneScheduleReminders { activity -> + activity.supportFragmentManager.commit { + replace( + R.id.fragment_container, + ReminderTroubleshootingFragment.newInstance(FragmentHost.STANDALONE_ACTIVITY), + ) + } + advanceRobolectricLooper() + activity.simulateSystemBars() + val binding = + FragmentReminderTroubleshootingBinding.bind( + activity.supportFragmentManager + .findFragmentById(R.id.fragment_container)!! + .requireView(), + ) + // scrolled to the end: the last check must clear the navigation bar band + binding.scrollView.scrollTo(0, binding.scrollView.getChildAt(0).bottom) + advanceRobolectricLooper() + captureScreen("standaloneActivityHost_troubleshooting_systemBars") + } + } + + /** Inserts [count] reminders so the list has content to render behind the simulated bars */ + private fun insertReminders(count: Int) { + runBlocking { + repeat(count) { index -> + ReviewRemindersDatabase.insertReminder( + ReviewReminder.createReviewReminder(ReviewReminderTime(hour = 8 + index, minute = 0)), ) } } @@ -158,4 +231,39 @@ class ReviewRemindersScreenshotTest : ScreenshotTest() { advanceRobolectricLooper() captureScreen("${prefix}_troubleshooting") } + + /** Launches [ScheduleRemindersFragment] hosted in the settings screen */ + private fun withSettingsScheduleReminders(block: (PreferencesActivity, FragmentManager) -> Unit) { + ActivityScenario.launch(PreferencesActivity.getIntent(targetContext)).use { scenario -> + scenario.onActivity { activity -> + val fm = (activity.fragment as PreferencesFragment).childFragmentManager + fm.commit { + replace( + R.id.settings_container, + ScheduleRemindersFragment.newInstance(ReviewReminderScope.Global, FragmentHost.SETTINGS), + ) + } + advanceRobolectricLooper() + block(activity, fm) + } + } + } + + /** Launches [ScheduleRemindersFragment] in its standalone activity */ + private fun withStandaloneScheduleReminders(block: (ConfigAwareSingleFragmentActivity) -> Unit) { + val intent = ScheduleRemindersFragment.getIntent(targetContext, ReviewReminderScope.Global) + ActivityScenario.launch(intent).use { scenario -> + advanceRobolectricLooper() + scenario.onActivity { activity -> block(activity) } + } + } + + /** Collapses the settings host's toolbar, as when the list has been scrolled */ + private fun FragmentManager.collapseToolbar() { + findFragmentById(R.id.settings_container) + ?.view + ?.findViewById(R.id.appbar) + ?.setExpanded(false, false) + advanceRobolectricLooper() + } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/utils/InsetsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/utils/InsetsTest.kt new file mode 100644 index 000000000000..6dfe9309cc51 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/utils/InsetsTest.kt @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.utils + +import android.view.View +import android.view.ViewGroup +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.marginBottom +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class InsetsTest { + private val view = + View(ApplicationProvider.getApplicationContext()).apply { + setPadding(0, 0, 0, 84) + layoutParams = ViewGroup.MarginLayoutParams(0, 0).apply { bottomMargin = 16 } + } + + @Test + fun `doOnApplyWindowInsets - insets add to the initial values, not the current ones`() { + view.doOnApplyWindowInsets { v, insets, initial -> + val bars = insets.getInsets(systemBars()) + v.updatePadding(bottom = initial.padding.bottom + bars.bottom) + v.updateLayoutParams { + bottomMargin = initial.margins.bottom + bars.bottom + } + } + + repeat(2) { view.dispatchInsets(navBarBottom = 48) } + + assertThat( + "padding is not compounded by the second dispatch", + view.paddingBottom, + equalTo(84 + 48), + ) + assertThat( + "margin is not compounded by the second dispatch", + view.marginBottom, + equalTo(16 + 48), + ) + } + + private fun View.dispatchInsets(navBarBottom: Int) { + val insets = + WindowInsetsCompat + .Builder() + .setInsets(systemBars(), Insets.of(0, 0, 0, navBarBottom)) + .build() + ViewCompat.dispatchApplyWindowInsets(this, insets) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/InsetsUtils.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/InsetsUtils.kt index f5b31ab8ca6d..399d9ccd445a 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/InsetsUtils.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/InsetsUtils.kt @@ -2,8 +2,20 @@ package com.ichi2.testutils +import android.annotation.SuppressLint +import android.app.Activity import android.content.Context +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.core.view.WindowInsetsCompat.Type.displayCutout +import androidx.core.view.WindowInsetsCompat.Type.navigationBars +import androidx.core.view.WindowInsetsCompat.Type.statusBars +import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooper import com.ichi2.utils.Dp import com.ichi2.utils.dp @@ -31,3 +43,40 @@ fun insetsOf( right.toPx(context), bottom.toPx(context), ) + +/** + * Injects insets to simulate an edge-to-edge on a real device. + * + * Overlays are displayed as translucent bands so content can be drawn behind them. + * + * @param cutoutLeft simulates a display cutout on the left edge, as when a phone with a + * top notch is rotated to landscape + */ +@SuppressLint("RtlHardcoded") // insets and cutouts are physical: not layout-direction relative +fun Activity.simulateSystemBars(cutoutLeft: Dp = 0.dp) { + val statusBarHeight = 24.dp + val navBarHeight = 48.dp + val insets = + WindowInsetsCompat + .Builder() + .setInsets(statusBars(), insetsOf(top = statusBarHeight)) + .setInsets(navigationBars(), insetsOf(bottom = navBarHeight)) + .setInsets(displayCutout(), insetsOf(left = cutoutLeft)) + .build() + ViewCompat.dispatchApplyWindowInsets(findViewById(android.R.id.content), insets) + + val decor = window.decorView as ViewGroup + val context: Context = this + val bands = + buildList { + add(FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, statusBarHeight.toPx(context), Gravity.TOP)) + add(FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, navBarHeight.toPx(context), Gravity.BOTTOM)) + if (cutoutLeft.dp > 0) { + add(FrameLayout.LayoutParams(cutoutLeft.toPx(context), FrameLayout.LayoutParams.MATCH_PARENT, Gravity.LEFT)) + } + } + bands.forEach { params -> + decor.addView(View(this).apply { setBackgroundColor(0x80000000.toInt()) }, params) + } + advanceRobolectricLooper() +}