diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/DeviceConfigurationOverrideWindowInsets.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/DeviceConfigurationOverrideWindowInsets.kt new file mode 100644 index 0000000000..463358e3c3 --- /dev/null +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/DeviceConfigurationOverrideWindowInsets.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.ui + +import android.view.WindowInsets +import android.widget.FrameLayout +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.children + +/** + * A [DeviceConfigurationOverride] that allows overriding the [windowInsets] available + * to the content under test. + */ +fun DeviceConfigurationOverride.Companion.WindowInsets( + windowInsets: WindowInsetsCompat +): DeviceConfigurationOverride = DeviceConfigurationOverride { contentUnderTest -> + val currentContentUnderTest by rememberUpdatedState(contentUnderTest) + val currentWindowInsets by rememberUpdatedState(windowInsets) + AndroidView( + factory = { context -> + object : FrameLayout(context) { + override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets { + children.forEach { + it.dispatchApplyWindowInsets(currentWindowInsets.toWindowInsets()) + } + return WindowInsetsCompat.CONSUMED.toWindowInsets()!! + } + + /** + * Deprecated, but intercept the `requestApplyInsets` call via the deprecated + * method. + */ + @Deprecated("Deprecated in Java") + override fun requestFitSystemWindows() { + dispatchApplyWindowInsets(currentWindowInsets.toWindowInsets()!!) + } + }.apply { + addView( + ComposeView(context).apply { + setContent { + currentContentUnderTest() + } + } + ) + } + }, + ) +} diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarInsetsScreenshotTests.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarInsetsScreenshotTests.kt new file mode 100644 index 0000000000..1be556c923 --- /dev/null +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarInsetsScreenshotTests.kt @@ -0,0 +1,355 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsEndWidth +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.windowInsetsStartWidth +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.material3.SnackbarDuration.Indefinite +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.Posture +import androidx.compose.material3.adaptive.WindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Color.Companion +import androidx.compose.ui.graphics.toAndroidRect +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.test.ForcedSize +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.then +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.roundToIntRect +import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat +import androidx.window.core.layout.WindowSizeClass +import com.github.takahirom.roborazzi.captureRoboImage +import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository +import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor +import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor +import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme +import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions +import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.LooperMode +import java.util.TimeZone +import javax.inject.Inject + +/** + * Tests that the Snackbar is correctly displayed on different screen sizes. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +// Configure Robolectric to use a very large screen size that can fit all of the test sizes. +// This allows enough room to render the content under test without clipping or scaling. +@Config(application = HiltTestApplication::class, qualifiers = "w1000dp-h1000dp-480dpi") +@LooperMode(LooperMode.Mode.PAUSED) +@HiltAndroidTest +class SnackbarInsetsScreenshotTests { + + /** + * Manages the components' state and is used to perform injection on your test + */ + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + /** + * Create a temporary folder used to create a Data Store file. This guarantees that + * the file is removed in between each test, preventing a crash. + */ + @BindValue + @get:Rule(order = 1) + val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() + + /** + * Use a test activity to set the content on. + */ + @get:Rule(order = 2) + val composeTestRule = createAndroidComposeRule() + + @Inject + lateinit var networkMonitor: NetworkMonitor + + @Inject + lateinit var timeZoneMonitor: TimeZoneMonitor + + @Inject + lateinit var userDataRepository: FakeUserDataRepository + + @Inject + lateinit var topicsRepository: TopicsRepository + + @Inject + lateinit var userNewsResourceRepository: UserNewsResourceRepository + + @Before + fun setup() { + hiltRule.inject() + + // Configure user data + runBlocking { + userDataRepository.setShouldHideOnboarding(true) + + userDataRepository.setFollowedTopicIds( + setOf(topicsRepository.getTopics().first().first().id), + ) + } + } + + @Before + fun setTimeZone() { + // Make time zone deterministic in tests + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + @Test + fun phone_noSnackbar() { + val snackbarHostState = SnackbarHostState() + testSnackbarScreenshotWithSize( + snackbarHostState, + 400.dp, + 500.dp, + "insets_snackbar_compact_medium_noSnackbar", + action = { }, + ) + } + + @Test + fun snackbarShown_phone() { + val snackbarHostState = SnackbarHostState() + testSnackbarScreenshotWithSize( + snackbarHostState, + 400.dp, + 500.dp, + "insets_snackbar_compact_medium", + ) { + snackbarHostState.showSnackbar( + "This is a test snackbar message", + actionLabel = "Action Label", + duration = Indefinite, + ) + } + } + + @Test + fun snackbarShown_foldable() { + val snackbarHostState = SnackbarHostState() + testSnackbarScreenshotWithSize( + snackbarHostState, + 600.dp, + 600.dp, + "insets_snackbar_medium_medium", + ) { + snackbarHostState.showSnackbar( + "This is a test snackbar message", + actionLabel = "Action Label", + duration = Indefinite, + ) + } + } + + @Test + fun snackbarShown_tablet() { + val snackbarHostState = SnackbarHostState() + testSnackbarScreenshotWithSize( + snackbarHostState, + 900.dp, + 900.dp, + "insets_snackbar_expanded_expanded", + ) { + snackbarHostState.showSnackbar( + "This is a test snackbar message", + actionLabel = "Action Label", + duration = Indefinite, + ) + } + } + + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + private fun testSnackbarScreenshotWithSize( + snackbarHostState: SnackbarHostState, + width: Dp, + height: Dp, + screenshotName: String, + action: suspend () -> Unit, + ) { + lateinit var scope: CoroutineScope + composeTestRule.setContent { + CompositionLocalProvider( + // Replaces images with placeholders + LocalInspectionMode provides true, + ) { + scope = rememberCoroutineScope() + + DeviceConfigurationOverride( + DeviceConfigurationOverride.ForcedSize(DpSize(width, height)) + ) { + DeviceConfigurationOverride( + DeviceConfigurationOverride.WindowInsets( + WindowInsetsCompat.Builder() + .setInsets( + WindowInsetsCompat.Type.statusBars(), + DpRect( + left = 0.dp, + top = 64.dp, + right = 0.dp, + bottom = 0.dp + ).toInsets(), + ) + .setInsets( + WindowInsetsCompat.Type.navigationBars(), + DpRect( + left = 64.dp, + top = 0.dp, + right = 64.dp, + bottom = 64.dp + ).toInsets(), + ) + .build(), + ), + ) { + BoxWithConstraints(Modifier.testTag("root")) { + NiaTheme { + val appState = rememberNiaAppState( + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + timeZoneMonitor = timeZoneMonitor, + ) + NiaApp( + appState = appState, + snackbarHostState = snackbarHostState, + showSettingsDialog = false, + onSettingsDismissed = {}, + onTopAppBarActionClick = {}, + windowAdaptiveInfo = WindowAdaptiveInfo( + windowSizeClass = WindowSizeClass.compute( + maxWidth.value, + maxHeight.value, + ), + windowPosture = Posture(), + ), + ) + DebugVisibleWindowInsets() + } + } + } + } + } + } + + scope.launch { + action() + } + + composeTestRule.onNodeWithTag("root") + .captureRoboImage( + "src/testDemo/screenshots/$screenshotName.png", + roborazziOptions = DefaultRoborazziOptions, + ) + } +} + +@Composable +fun DebugVisibleWindowInsets( + modifier: Modifier = Modifier, + debugColor: Color = Color.Magenta.copy(alpha = 0.5f) +) { + Box(modifier = modifier.fillMaxSize()) { + Spacer( + modifier = Modifier + .align(Alignment.CenterStart) + .fillMaxHeight() + .windowInsetsStartWidth(WindowInsets.safeDrawing) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .background(debugColor), + ) + Spacer( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .windowInsetsEndWidth(WindowInsets.safeDrawing) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .background(debugColor), + ) + Spacer( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .windowInsetsTopHeight(WindowInsets.safeDrawing) + .background(debugColor), + ) + Spacer( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .windowInsetsBottomHeight(WindowInsets.safeDrawing) + .background(debugColor), + ) + } +} + +@Composable +private fun DpRect.toInsets() = toInsets(LocalDensity.current) + +private fun DpRect.toInsets(density: Density) = + Insets.of(with(density) { toRect() }.roundToIntRect().toAndroidRect()) + + diff --git a/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png index 912fca4c78..0e732ac74e 100644 Binary files a/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png and b/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png index e052b59206..9a1de8eac7 100644 Binary files a/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png and b/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png index 668d691466..564b6ed789 100644 Binary files a/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png and b/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationBar.png index 1daf5ec34b..fe053ff18c 100644 Binary files a/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationBar.png and b/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png index 53bf6f3c5e..a7b404cf3d 100644 Binary files a/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png and b/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png index c5b7fe883b..ed5ef18bd3 100644 Binary files a/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png and b/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/insets_snackbar_compact_medium.png b/app/src/testDemo/screenshots/insets_snackbar_compact_medium.png new file mode 100644 index 0000000000..7857cd7f2b Binary files /dev/null and b/app/src/testDemo/screenshots/insets_snackbar_compact_medium.png differ diff --git a/app/src/testDemo/screenshots/insets_snackbar_compact_medium_noSnackbar.png b/app/src/testDemo/screenshots/insets_snackbar_compact_medium_noSnackbar.png new file mode 100644 index 0000000000..4eaed573eb Binary files /dev/null and b/app/src/testDemo/screenshots/insets_snackbar_compact_medium_noSnackbar.png differ diff --git a/app/src/testDemo/screenshots/insets_snackbar_expanded_expanded.png b/app/src/testDemo/screenshots/insets_snackbar_expanded_expanded.png new file mode 100644 index 0000000000..946207ae65 Binary files /dev/null and b/app/src/testDemo/screenshots/insets_snackbar_expanded_expanded.png differ diff --git a/app/src/testDemo/screenshots/insets_snackbar_medium_medium.png b/app/src/testDemo/screenshots/insets_snackbar_medium_medium.png new file mode 100644 index 0000000000..4b9a17933e Binary files /dev/null and b/app/src/testDemo/screenshots/insets_snackbar_medium_medium.png differ diff --git a/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationBar.png index 4bc5d2b1cc..94b28c16c6 100644 Binary files a/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationBar.png and b/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png index 3e38938d63..e96360a390 100644 Binary files a/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png and b/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png index f914a0454d..28099341b2 100644 Binary files a/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png and b/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/snackbar_compact_medium.png b/app/src/testDemo/screenshots/snackbar_compact_medium.png index 7676de40a5..01ee209f4b 100644 Binary files a/app/src/testDemo/screenshots/snackbar_compact_medium.png and b/app/src/testDemo/screenshots/snackbar_compact_medium.png differ diff --git a/app/src/testDemo/screenshots/snackbar_compact_medium_noSnackbar.png b/app/src/testDemo/screenshots/snackbar_compact_medium_noSnackbar.png index ff9ed76691..b7436771e1 100644 Binary files a/app/src/testDemo/screenshots/snackbar_compact_medium_noSnackbar.png and b/app/src/testDemo/screenshots/snackbar_compact_medium_noSnackbar.png differ diff --git a/app/src/testDemo/screenshots/snackbar_expanded_expanded.png b/app/src/testDemo/screenshots/snackbar_expanded_expanded.png index 4997a83af7..88be2bb8ed 100644 Binary files a/app/src/testDemo/screenshots/snackbar_expanded_expanded.png and b/app/src/testDemo/screenshots/snackbar_expanded_expanded.png differ diff --git a/app/src/testDemo/screenshots/snackbar_medium_medium.png b/app/src/testDemo/screenshots/snackbar_medium_medium.png index 36fffa9c6a..d06a31a791 100644 Binary files a/app/src/testDemo/screenshots/snackbar_medium_medium.png and b/app/src/testDemo/screenshots/snackbar_medium_medium.png differ