diff --git a/Jetsnack/app/build.gradle.kts b/Jetsnack/app/build.gradle.kts index 0fca21037b..ac063d7451 100644 --- a/Jetsnack/app/build.gradle.kts +++ b/Jetsnack/app/build.gradle.kts @@ -121,6 +121,10 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.runtime.tracing) + implementation("androidx.tracing:tracing-perfetto:1.0.1") + implementation("androidx.tracing:tracing-perfetto-binary:1.0.1") + implementation("androidx.tracing:tracing-perfetto-handshake:1.0.1") implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.ui) diff --git a/Jetsnack/app/proguard-benchmark-rules.pro b/Jetsnack/app/proguard-benchmark-rules.pro index 5849b43aae..dcd54869dc 100644 --- a/Jetsnack/app/proguard-benchmark-rules.pro +++ b/Jetsnack/app/proguard-benchmark-rules.pro @@ -25,4 +25,7 @@ # When generating the baseline profile we want the proper names of # the methods and classes --dontobfuscate \ No newline at end of file +-dontobfuscate + +-keep class androidx.tracing.perfetto.** { *; } +-keep class androidx.compose.runtime.tracing.** { *; } \ No newline at end of file diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt index b98bfae26b..eaa68b6901 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt @@ -34,8 +34,8 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.navigation.NavBackStackEntry @@ -166,5 +166,5 @@ fun MainContainer(modifier: Modifier = Modifier, onSnackSelected: (Long, String, } } -val LocalNavAnimatedVisibilityScope = compositionLocalOf { null } -val LocalSharedTransitionScope = compositionLocalOf { null } +val LocalNavAnimatedVisibilityScope = staticCompositionLocalOf { null } +val LocalSharedTransitionScope = staticCompositionLocalOf { null } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt index 028ee2b1ce..210121e044 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt @@ -21,10 +21,12 @@ package com.example.jetsnack.ui.components import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.annotation.DrawableRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.EnterExitState import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.SharedTransitionScope.OverlayClip import androidx.compose.animation.core.animateDp import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -56,14 +58,21 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onLayoutRectChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Density @@ -96,6 +105,8 @@ fun SnackCollection( modifier: Modifier = Modifier, index: Int = 0, highlight: Boolean = true, + sharedTransitionScope: SharedTransitionScope? = LocalSharedTransitionScope.current, + animatedVisibilityScope: AnimatedVisibilityScope? = LocalNavAnimatedVisibilityScope.current, ) { Column(modifier = modifier) { Row( @@ -126,9 +137,22 @@ fun SnackCollection( } } if (highlight && snackCollection.type == CollectionType.Highlight) { - HighlightedSnacks(snackCollection.id, index, snackCollection.snacks, onSnackClick) + HighlightedSnacks( + snackCollection.id, + index, + snackCollection.snacks, + onSnackClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) } else { - Snacks(snackCollection.id, snackCollection.snacks, onSnackClick) + Snacks( + snackCollection.id, + snackCollection.snacks, + onSnackClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) } } } @@ -140,6 +164,8 @@ private fun HighlightedSnacks( snacks: List, onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier, + sharedTransitionScope: SharedTransitionScope? = LocalSharedTransitionScope.current, + animatedVisibilityScope: AnimatedVisibilityScope? = LocalNavAnimatedVisibilityScope.current, ) { val rowState = rememberLazyListState() val cardWidthWithPaddingPx = with(LocalDensity.current) { cardWidthWithPaddingPx } @@ -169,55 +195,80 @@ private fun HighlightedSnacks( index = index, gradient = gradient, scrollProvider = scrollProvider, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, ) } } } @Composable -private fun Snacks(snackCollectionId: Long, snacks: List, onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier) { +private fun Snacks( + snackCollectionId: Long, + snacks: List, + onSnackClick: (Long, String) -> Unit, + modifier: Modifier = Modifier, + sharedTransitionScope: SharedTransitionScope? = LocalSharedTransitionScope.current, + animatedVisibilityScope: AnimatedVisibilityScope? = LocalNavAnimatedVisibilityScope.current, +) { LazyRow( modifier = modifier, contentPadding = PaddingValues(start = 12.dp, end = 12.dp), ) { items(snacks) { snack -> - SnackItem(snack, snackCollectionId, onSnackClick) + SnackItem( + snack, + snackCollectionId, + onSnackClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) } } } @Composable -fun SnackItem(snack: Snack, snackCollectionId: Long, onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier) { +fun SnackItem( + snack: Snack, + snackCollectionId: Long, + onSnackClick: (Long, String) -> Unit, + modifier: Modifier = Modifier, + sharedTransitionScope: SharedTransitionScope? = LocalSharedTransitionScope.current, + animatedVisibilityScope: AnimatedVisibilityScope? = LocalNavAnimatedVisibilityScope.current, +) { + var isVisible by remember { mutableStateOf(false) } + JetsnackSurface( shape = MaterialTheme.shapes.medium, - modifier = modifier.padding( - start = 4.dp, - end = 4.dp, - bottom = 8.dp, - ), - + modifier = modifier + .padding( + start = 4.dp, + end = 4.dp, + bottom = 8.dp, + ) + .onLayoutRectChanged { bounds -> + val visible = bounds.fractionVisibleInWindow() > 0f + if (visible != isVisible) { + isVisible = visible + } + }, ) { - val sharedTransitionScope = LocalSharedTransitionScope.current - ?: throw IllegalStateException("No sharedTransitionScope found") - val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current - ?: throw IllegalStateException("No animatedVisibilityScope found") - - with(sharedTransitionScope) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .clickable(onClick = { - onSnackClick(snack.id, snackCollectionId.toString()) - }) - .padding(8.dp), - ) { - SnackImage( - imageRes = snack.imageRes, - elevation = 1.dp, - contentDescription = null, + if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .size(120.dp) - .sharedBounds( + .semantics { + testTag = "snack_item" + testTagsAsResourceId = true + } + .clickable(onClick = { + onSnackClick(snack.id, snackCollectionId.toString()) + }) + .padding(8.dp), + ) { + val imageSharedBoundsModifier = if (isVisible) { + Modifier.sharedBounds( rememberSharedContentState( key = SnackSharedElementKey( snackId = snack.id, @@ -227,16 +278,22 @@ fun SnackItem(snack: Snack, snackCollectionId: Long, onSnackClick: (Long, String ), animatedVisibilityScope = animatedVisibilityScope, boundsTransform = snackDetailBoundsTransform, - ), - ) - Text( - text = snack.name, - style = MaterialTheme.typography.titleMedium, - color = JetsnackTheme.colors.textSecondary, - modifier = Modifier - .padding(top = 8.dp) - .wrapContentWidth() - .sharedBounds( + ) + } else { + Modifier + } + + SnackImage( + imageRes = snack.imageRes, + elevation = 1.dp, + contentDescription = null, + modifier = Modifier + .size(120.dp) + .then(imageSharedBoundsModifier), + ) + + val textSharedBoundsModifier = if (isVisible) { + Modifier.sharedBounds( rememberSharedContentState( key = SnackSharedElementKey( snackId = snack.id, @@ -249,8 +306,21 @@ fun SnackItem(snack: Snack, snackCollectionId: Long, onSnackClick: (Long, String exit = fadeOut(nonSpatialExpressiveSpring()), resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), boundsTransform = snackDetailBoundsTransform, - ), - ) + ) + } else { + Modifier + } + + Text( + text = snack.name, + style = MaterialTheme.typography.titleMedium, + color = JetsnackTheme.colors.textSecondary, + modifier = Modifier + .padding(top = 8.dp) + .wrapContentWidth() + .then(textSharedBoundsModifier), + ) + } } } } @@ -265,26 +335,23 @@ private fun HighlightSnackItem( gradient: List, scrollProvider: () -> Float, modifier: Modifier = Modifier, + sharedTransitionScope: SharedTransitionScope? = LocalSharedTransitionScope.current, + animatedVisibilityScope: AnimatedVisibilityScope? = LocalNavAnimatedVisibilityScope.current, ) { - val sharedTransitionScope = LocalSharedTransitionScope.current - ?: throw IllegalStateException("No Scope found") - val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current - ?: throw IllegalStateException("No Scope found") - with(sharedTransitionScope) { - val roundedCornerAnimation by animatedVisibilityScope.transition - .animateDp(label = "rounded corner") { enterExit: EnterExitState -> - when (enterExit) { - EnterExitState.PreEnter -> 0.dp - EnterExitState.Visible -> 20.dp - EnterExitState.PostExit -> 20.dp + var isVisible by remember { mutableStateOf(false) } + if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + val roundedCornerAnimation by animatedVisibilityScope.transition + .animateDp(label = "rounded corner") { enterExit: EnterExitState -> + when (enterExit) { + EnterExitState.PreEnter -> 0.dp + EnterExitState.Visible -> 20.dp + EnterExitState.PostExit -> 20.dp + } } - } - JetsnackCard( - elevation = 0.dp, - shape = RoundedCornerShape(roundedCornerAnimation), - modifier = modifier - .padding(bottom = 16.dp) - .sharedBounds( + + val cardSharedBoundsModifier = if (isVisible) { + Modifier.sharedBounds( sharedContentState = rememberSharedContentState( key = SnackSharedElementKey( snackId = snack.id, @@ -302,36 +369,55 @@ private fun HighlightSnackItem( enter = fadeIn(), exit = fadeOut(), ) - .size( - width = HighlightCardWidth, - height = 250.dp, - ) - .border( - 1.dp, - JetsnackTheme.colors.uiBorder.copy(alpha = 0.12f), - RoundedCornerShape(roundedCornerAnimation), - ), + } else { + Modifier + } - ) { - Column( - modifier = Modifier - .clickable(onClick = { - onSnackClick( - snack.id, - snackCollectionId.toString(), - ) - }) - .fillMaxSize(), + JetsnackCard( + elevation = 0.dp, + shape = RoundedCornerShape(roundedCornerAnimation), + modifier = modifier + .padding(bottom = 16.dp) + .onLayoutRectChanged { bounds -> + val visible = bounds.fractionVisibleInWindow() > 0f + if (visible != isVisible) { + isVisible = visible + } + } + .then(cardSharedBoundsModifier) + .size( + width = HighlightCardWidth, + height = 250.dp, + ) + .border( + 1.dp, + JetsnackTheme.colors.uiBorder.copy(alpha = 0.12f), + RoundedCornerShape(roundedCornerAnimation), + ), ) { - Box( + Column( modifier = Modifier - .height(160.dp) - .fillMaxWidth(), + .semantics { + testTag = "highlight_snack_item" + testTagsAsResourceId = true + } + .clickable(onClick = { + onSnackClick( + snack.id, + snackCollectionId.toString(), + ) + }) + .fillMaxSize(), + ) { Box( modifier = Modifier - .sharedBounds( + .height(160.dp) + .fillMaxWidth(), + ) { + val bgSharedBoundsModifier = if (isVisible) { + Modifier.sharedBounds( rememberSharedContentState( key = SnackSharedElementKey( snackId = snack.id, @@ -345,28 +431,30 @@ private fun HighlightSnackItem( exit = fadeOut(nonSpatialExpressiveSpring()), resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), ) - .height(100.dp) - .fillMaxWidth() - .offsetGradientBackground( - colors = gradient, - width = { - // The Cards show a gradient which spans 6 cards and - // scrolls with parallax. - 6 * cardWidthWithPaddingPx - }, - offset = { - val left = index * cardWidthWithPaddingPx - val gradientOffset = left - (scrollProvider() / 3f) - gradientOffset - }, - ), - ) + } else { + Modifier + } - SnackImage( - imageRes = snack.imageRes, - contentDescription = null, - modifier = Modifier - .sharedBounds( + Box( + modifier = Modifier + .then(bgSharedBoundsModifier) + .height(100.dp) + .fillMaxWidth() + .offsetGradientBackground( + colors = gradient, + width = { + 6 * cardWidthWithPaddingPx + }, + offset = { + val left = index * cardWidthWithPaddingPx + val gradientOffset = left - (scrollProvider() / 3f) + gradientOffset + }, + ), + ) + + val imageSharedBoundsModifier = if (isVisible) { + Modifier.sharedBounds( rememberSharedContentState( key = SnackSharedElementKey( snackId = snack.id, @@ -379,21 +467,24 @@ private fun HighlightSnackItem( enter = fadeIn(nonSpatialExpressiveSpring()), boundsTransform = snackDetailBoundsTransform, ) - .align(Alignment.BottomCenter) - .size(120.dp), - ) - } + } else { + Modifier + } - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = snack.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleLarge, - color = JetsnackTheme.colors.textSecondary, - modifier = Modifier - .padding(horizontal = 16.dp) - .sharedBounds( + SnackImage( + imageRes = snack.imageRes, + contentDescription = null, + modifier = Modifier + .then(imageSharedBoundsModifier) + .align(Alignment.BottomCenter) + .size(120.dp), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + val titleSharedBoundsModifier = if (isVisible) { + Modifier.sharedBounds( rememberSharedContentState( key = SnackSharedElementKey( snackId = snack.id, @@ -407,17 +498,25 @@ private fun HighlightSnackItem( boundsTransform = snackDetailBoundsTransform, resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), ) - .wrapContentWidth(), - ) - Spacer(modifier = Modifier.height(4.dp)) + } else { + Modifier + } - Text( - text = snack.tagline, - style = MaterialTheme.typography.bodyLarge, - color = JetsnackTheme.colors.textHelp, - modifier = Modifier - .padding(horizontal = 16.dp) - .sharedBounds( + Text( + text = snack.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleLarge, + color = JetsnackTheme.colors.textSecondary, + modifier = Modifier + .padding(horizontal = 16.dp) + .then(titleSharedBoundsModifier) + .wrapContentWidth(), + ) + Spacer(modifier = Modifier.height(4.dp)) + + val taglineSharedBoundsModifier = if (isVisible) { + Modifier.sharedBounds( rememberSharedContentState( key = SnackSharedElementKey( snackId = snack.id, @@ -431,8 +530,20 @@ private fun HighlightSnackItem( boundsTransform = snackDetailBoundsTransform, resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), ) - .wrapContentWidth(), - ) + } else { + Modifier + } + + Text( + text = snack.tagline, + style = MaterialTheme.typography.bodyLarge, + color = JetsnackTheme.colors.textHelp, + modifier = Modifier + .padding(horizontal = 16.dp) + .then(taglineSharedBoundsModifier) + .wrapContentWidth(), + ) + } } } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Surface.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Surface.kt index 1104c51f1e..eac8dcbc53 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Surface.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Surface.kt @@ -61,7 +61,11 @@ fun JetsnackSurface( ) .clip(shape), ) { - CompositionLocalProvider(LocalContentColor provides contentColor, content = content) + if (contentColor != LocalContentColor.current) { + CompositionLocalProvider(LocalContentColor provides contentColor, content = content) + } else { + content() + } } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt index 10e3125ce5..8f9f7946d5 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt @@ -40,6 +40,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.jetsnack.model.Filter @@ -108,7 +111,12 @@ private fun SnackCollectionList( sharedTransitionScope: SharedTransitionScope, modifier: Modifier = Modifier, ) { - LazyColumn(modifier = modifier) { + LazyColumn( + modifier = modifier.semantics { + testTag = "feed_list" + testTagsAsResourceId = true + }, + ) { item { Spacer( Modifier.windowInsetsTopHeight( @@ -131,6 +139,7 @@ private fun SnackCollectionList( snackCollection = snackCollection, onSnackClick = onSnackClick, index = index, + sharedTransitionScope = sharedTransitionScope, ) } } diff --git a/Jetsnack/benchmark/build.gradle.kts b/Jetsnack/benchmark/build.gradle.kts new file mode 100644 index 0000000000..a6961ffc78 --- /dev/null +++ b/Jetsnack/benchmark/build.gradle.kts @@ -0,0 +1,62 @@ +/* + * 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. + */ + + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.test) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "com.example.jetsnack.benchmark" + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + // This benchmark build type is matching the type in the target application + create("benchmark") { + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks.add("release") + } + } + + kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + targetProjectPath = ":app" + experimentalProperties["android.experimental.self-instrumenting"] = true +} + +dependencies { + implementation(libs.androidx.test.rules) + implementation(libs.androidx.test.runner) + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.test.uiautomator) + implementation(libs.androidx.benchmark.macro.junit4) +} diff --git a/Jetsnack/benchmark/src/main/AndroidManifest.xml b/Jetsnack/benchmark/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..9a40236b94 --- /dev/null +++ b/Jetsnack/benchmark/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/Jetsnack/benchmark/src/main/java/com/example/jetsnack/benchmark/JetsnackBenchmarks.kt b/Jetsnack/benchmark/src/main/java/com/example/jetsnack/benchmark/JetsnackBenchmarks.kt new file mode 100644 index 0000000000..72370aca83 --- /dev/null +++ b/Jetsnack/benchmark/src/main/java/com/example/jetsnack/benchmark/JetsnackBenchmarks.kt @@ -0,0 +1,122 @@ +/* + * 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.example.jetsnack.benchmark + +import android.content.Intent +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Direction +import androidx.test.uiautomator.Until +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class JetsnackBenchmarks { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + private val targetPackageName = "com.example.jetsnack" + + @Test + fun scrollFeedFrameTiming() = benchmarkRule.measureRepeated( + packageName = targetPackageName, + metrics = listOf(FrameTimingMetric()), + compilationMode = CompilationMode.DEFAULT, + startupMode = StartupMode.WARM, + iterations = 5, + setupBlock = { + pressHome() + val context = InstrumentationRegistry.getInstrumentation().context + val intent = context.packageManager.getLaunchIntentForPackage(packageName)!! + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + context.startActivity(intent) + device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 15000) + device.wait(Until.hasObject(By.res("feed_list")), 15000) + }, + ) { + val listSelector = By.res("feed_list") + + // Scroll down through feed + repeat(3) { + val list = device.findObject(listSelector) ?: device.findObject(By.scrollable(true)) + requireNotNull(list) { "Feed list not found!" } + list.setGestureMargin(device.displayWidth / 5) + list.fling(Direction.DOWN) + device.waitForIdle() + } + + // Scroll back up + repeat(3) { + val list = device.findObject(listSelector) ?: device.findObject(By.scrollable(true)) + requireNotNull(list) { "Feed list not found!" } + list.setGestureMargin(device.displayWidth / 5) + list.fling(Direction.UP) + device.waitForIdle() + } + } + + @Test + fun navigateSnackDetailFrameTiming() = benchmarkRule.measureRepeated( + packageName = targetPackageName, + metrics = listOf(FrameTimingMetric()), + compilationMode = CompilationMode.DEFAULT, + startupMode = StartupMode.WARM, + iterations = 5, + setupBlock = { + pressHome() + val context = InstrumentationRegistry.getInstrumentation().context + val intent = context.packageManager.getLaunchIntentForPackage(packageName)!! + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + context.startActivity(intent) + device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 15000) + device.wait(Until.hasObject(By.res("feed_list")), 15000) + device.wait(Until.hasObject(By.res("highlight_snack_item")), 10000) + device.waitForIdle() + }, + ) { + val listSelector = By.res("feed_list") + + // Find snack item to click and trigger shared element transition + val snackItem = device.wait(Until.findObject(By.text("Cupcake")), 10000) + ?: device.wait(Until.findObject(By.text("Donut")), 5000) + ?: device.wait(Until.findObject(By.res("highlight_snack_item")), 5000) + ?: device.findObject(By.res("snack_item")) + ?: device.findObject(By.clickable(true)) + requireNotNull(snackItem) { "Snack item not found on feed!" } + + // Click snack to navigate into detail with shared element transition + snackItem.click() + + // Wait for detail screen to appear + val detailLoaded = device.wait(Until.hasObject(By.desc("Back")), 10000) || + device.wait(Until.hasObject(By.text("Details")), 5000) || + device.wait(Until.hasObject(By.textContains("CART")), 5000) + check(detailLoaded) { "Detail screen not loaded!" } + device.waitForIdle() + + // Return to feed with reverse shared transition + device.pressBack() + device.wait(Until.hasObject(listSelector), 10000) + device.waitForIdle() + } +} diff --git a/Jetsnack/build.gradle.kts b/Jetsnack/build.gradle.kts index e0ceb9e193..8732b128e2 100644 --- a/Jetsnack/build.gradle.kts +++ b/Jetsnack/build.gradle.kts @@ -18,6 +18,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.version.catalog.update) alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.test) apply false alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.compose) apply false alias(libs.plugins.spotless) apply false diff --git a/Jetsnack/gradle/libs.versions.toml b/Jetsnack/gradle/libs.versions.toml index 88caeeb200..9a7d60def3 100644 --- a/Jetsnack/gradle/libs.versions.toml +++ b/Jetsnack/gradle/libs.versions.toml @@ -4,6 +4,7 @@ android-material3 = "1.14.0" androidGradlePlugin = "9.3.1" androidx-activity-compose = "1.13.0" androidx-appcompat = "1.8.0" +androidx-benchmark = "1.3.3" androidx-compose-bom = "2026.08.00" androidx-core-splashscreen = "1.2.0" androidx-corektx = "1.19.0" @@ -73,6 +74,7 @@ androidx-compose-material3-adaptive-navigationSuite = { module = "androidx.compo androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" } androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } +androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } @@ -110,6 +112,7 @@ androidx-test-ext-truth = { module = "androidx.test.ext:truth", version.ref = "a androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test" } androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androix-test-uiautomator" } +androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark" } androidx-tv-foundation = { module = "androidx.tv:tv-foundation", version.ref = "androidx-tv-foundation" } androidx-tv-material = { module = "androidx.tv:tv-material", version.ref = "androidx-tv-material" } androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "androidx-wear-compose" } diff --git a/Jetsnack/settings.gradle.kts b/Jetsnack/settings.gradle.kts index 3bc8533030..62c03f96a7 100644 --- a/Jetsnack/settings.gradle.kts +++ b/Jetsnack/settings.gradle.kts @@ -38,3 +38,4 @@ dependencyResolutionManagement { } rootProject.name = "Jetsnack" include(":app") +include(":benchmark")