diff --git a/androidApp/benchmark-rules.pro b/androidApp/benchmark-rules.pro new file mode 100644 index 00000000..5fa87d01 --- /dev/null +++ b/androidApp/benchmark-rules.pro @@ -0,0 +1,6 @@ +# Proguard rules for the `benchmark` build type. +# +# Obsfuscation must be disabled for the build variant that generates Baseline Profile, otherwise +# wrong symbols would be generated. The generated Baseline Profile will be properly applied when generated +# without obfuscation and your app is being obfuscated. +-dontobfuscate diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index ac5387a8..e23c5bf2 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -6,6 +6,7 @@ import java.util.Properties @Suppress("DSL_SCOPE_VIOLATION") // Remove when fixed https://youtrack.jetbrains.com/issue/KTIJ-19369 plugins { alias(libs.plugins.android.application) + alias(libs.plugins.baseline.profile) alias(libs.plugins.kotlin.android) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.ludi.common) @@ -48,6 +49,11 @@ android { signingConfig = signingConfigs.getByName("release") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } + create("benchmark") { + initWith(buildTypes.getByName("release")) + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += listOf("release") + } } kotlinOptions { @@ -96,6 +102,12 @@ appVersioning { } } +baselineProfile { + automaticGenerationDuringBuild = true + saveInSrc = true + mergeIntoMain = true +} + dependencies { // Core Android dependencies implementation(libs.androidx.core.ktx) @@ -104,6 +116,8 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.activity.compose) implementation(libs.splash.screen) + implementation(libs.profiler.installer) + baselineProfile(project(":benchmark")) implementation(project(":shared")) diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index d72214fa..e5649b28 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -19,6 +19,11 @@ android:theme="@style/Theme.Ludi.Splash" android:name=".LudiApplication" tools:targetApi="31"> + + + ("pixel7Api33") { + device = "Pixel 7" + apiLevel = 33 + systemImageSource = "aosp-atd" + } + } + } + } + + targetProjectPath = ":androidApp" +} + +baselineProfile { + managedDevices += "pixel7Api33" + useConnectedDevices = false +} + +dependencies { + implementation(libs.androidx.test.ext.junit) + implementation(libs.espresso.core) + implementation(libs.uiautomator) + implementation(libs.benchmark.macro.junit4) +} diff --git a/benchmark/src/main/AndroidManifest.xml b/benchmark/src/main/AndroidManifest.xml new file mode 100644 index 00000000..227314ee --- /dev/null +++ b/benchmark/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/benchmark/src/main/java/com/mr3y/ludi/benchmark/BaselineProfileGenerator.kt b/benchmark/src/main/java/com/mr3y/ludi/benchmark/BaselineProfileGenerator.kt new file mode 100644 index 00000000..945cd292 --- /dev/null +++ b/benchmark/src/main/java/com/mr3y/ludi/benchmark/BaselineProfileGenerator.kt @@ -0,0 +1,99 @@ +package com.mr3y.ludi.benchmark + +import androidx.benchmark.macro.MacrobenchmarkScope +import androidx.benchmark.macro.junit4.BaselineProfileRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +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) +@LargeTest +class BaselineProfileGenerator { + @get:Rule + val baselineProfileRule = BaselineProfileRule() + + @Test + fun generate() = baselineProfileRule.collect( + packageName = "com.mr3y.ludi", + stableIterations = 2, + maxIterations = 10, + profileBlock = { + // Start the app + pressHome() + startActivityAndWait() + + // simulate user journey through the app. + waitForGenresToLoad() + selectRandomGenres() + moveToNextPage() + + waitForGamesToLoad() + selectRandomGames() + scrollGamesContainerHorizontally() + moveToNextPage() + + waitForDataSources() + moveToNextPage() + } + ) + + fun MacrobenchmarkScope.waitForGenresToLoad() { + device.wait(Until.gone(By.res("onboarding:genres:loadingWheel")), 10_000L) + device.wait(Until.hasObject(By.res("onboarding:genres:content")), 1000L) + } + + fun MacrobenchmarkScope.selectRandomGenres() { + val genres = device.findObject(By.res("onboarding:genres:content")).children + var i = 0 + while (i < 3) { + val genre = genres.random() + if (genre.isSelected) { + continue + } + genre.click() + device.waitForIdle() + i++ + } + } + + fun MacrobenchmarkScope.waitForGamesToLoad() { + device.wait(Until.gone(By.res("onboarding:games:gameLoading")), 10_000L) + device.wait(Until.hasObject(By.res("onboarding:games:gameContent")), 1000L) + } + + fun MacrobenchmarkScope.selectRandomGames() { + val gamesContainer = device.waitForObject(By.res("onboarding:games:suggestedGames")) + + // Set gesture margin from sides not to trigger system gesture navigation + val horizontalMargin = 10 * gamesContainer.visibleBounds.width() / 100 + gamesContainer.setGestureMargins(horizontalMargin, 0, horizontalMargin, 0) + val games = gamesContainer.children.filter { it.resourceName == "onboarding:games:gameContent" } + repeat(3) { + games.random().click() + device.waitForIdle() + } + } + + fun MacrobenchmarkScope.scrollGamesContainerHorizontally() { + val gamesContainer = device.waitForObject(By.res("onboarding:games:suggestedGames")) + // Set gesture margin from sides not to trigger system gesture navigation + gamesContainer.setGestureMargin(device.displayWidth / 5) + gamesContainer.fling(Direction.RIGHT) + device.waitForIdle() + gamesContainer.fling(Direction.LEFT) + } + + fun MacrobenchmarkScope.waitForDataSources() { + device.wait(Until.hasObject(By.res("onboarding:datasources:content")), 10_000L) + } + + fun MacrobenchmarkScope.moveToNextPage() { + device.findObject(By.res("onboarding:fab")).click() + device.waitForIdle() + } +} \ No newline at end of file diff --git a/benchmark/src/main/java/com/mr3y/ludi/benchmark/Utils.kt b/benchmark/src/main/java/com/mr3y/ludi/benchmark/Utils.kt new file mode 100644 index 00000000..dc60ac59 --- /dev/null +++ b/benchmark/src/main/java/com/mr3y/ludi/benchmark/Utils.kt @@ -0,0 +1,13 @@ +package com.mr3y.ludi.benchmark + +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until + +internal fun UiDevice.waitForObject(selector: BySelector, timeout: Long = 1000L): UiObject2 { + if (wait(Until.hasObject(selector), timeout)) { + return findObject(selector) + } + error("Object with selector [$selector] not found") +} diff --git a/build.gradle.kts b/build.gradle.kts index ec5fbe17..225e367c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.kotlin.android).apply(false) alias(libs.plugins.android.application).apply(false) alias(libs.plugins.android.library).apply(false) + alias(libs.plugins.android.test).apply(false) alias(libs.plugins.compose.multiplatform).apply(false) alias(libs.plugins.spotless.plugin).apply(false) alias(libs.plugins.ktlint.plugin).apply(false) diff --git a/convention-plugins/plugins/src/main/kotlin/com/mr3y/ludi/gradle/AndroidConventionPlugin.kt b/convention-plugins/plugins/src/main/kotlin/com/mr3y/ludi/gradle/AndroidConventionPlugin.kt index 43284f17..46dc4497 100644 --- a/convention-plugins/plugins/src/main/kotlin/com/mr3y/ludi/gradle/AndroidConventionPlugin.kt +++ b/convention-plugins/plugins/src/main/kotlin/com/mr3y/ludi/gradle/AndroidConventionPlugin.kt @@ -2,6 +2,7 @@ package com.mr3y.ludi.gradle import com.android.build.api.dsl.CommonExtension import com.android.build.gradle.LibraryExtension +import com.android.build.gradle.TestExtension import com.android.build.gradle.internal.dsl.BaseAppModuleExtension import org.gradle.api.JavaVersion import org.gradle.api.Plugin @@ -25,6 +26,12 @@ class AndroidConventionPlugin : Plugin { applyCommonAndroidConvention() } } + pluginManager.hasPlugin("com.android.test") -> { + val androidTestExtension = extensions.getByType() + androidTestExtension.apply { + applyCommonAndroidConvention() + } + } else -> {} } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf67d069..4f1776ac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -73,6 +73,8 @@ android-library = "com.android.library:8.1.2" ## ⬆ :8.3.0-alpha09" ## ⬆ :8.3.0-alpha10" ## ⬆ :8.3.0-alpha11" +android-test = "com.android.test:8.1.2" +baseline-profile = "androidx.baselineprofile:1.2.0" kotlin-android = "org.jetbrains.kotlin.android:1.9.10" kotlin-jvm = "org.jetbrains.kotlin.jvm:1.9.10" kotlin-multiplatform = "org.jetbrains.kotlin.multiplatform:1.9.10" @@ -229,6 +231,10 @@ junit = "junit:junit:4.13.2" strikt = "io.strikt:strikt-core:0.34.1" turbine = "app.cash.turbine:turbine:1.0.0" robolectric = "org.robolectric:robolectric:4.10.3" +espresso-core = "androidx.test.espresso:espresso-core:3.5.1" +uiautomator = "androidx.test.uiautomator:uiautomator:2.2.0" +profiler-installer = "androidx.profileinstaller:profileinstaller:1.3.1" +benchmark-macro-junit4 = "androidx.benchmark:benchmark-macro-junit4:1.2.0" ## ⬆ :4.11-beta-1" ## ⬆ :4.11-beta-2" ## ⬆ :4.11" diff --git a/settings.gradle.kts b/settings.gradle.kts index 1eec9d5b..7e59e166 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,4 +33,5 @@ dependencyResolutionManagement { rootProject.name = "Ludi" include(":androidApp") include(":shared") -include(":desktopApp") \ No newline at end of file +include(":desktopApp") +include(":benchmark") \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/App.kt b/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/App.kt index fe6086bd..f82e7f8c 100644 --- a/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/App.kt +++ b/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/App.kt @@ -1,5 +1,6 @@ package com.mr3y.ludi.shared +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass @@ -30,7 +31,9 @@ fun App( LocalWindowSizeClass provides calculateWindowSizeClass() ) { if (showOnboardingScreen) { - Navigator(screen = OnboardingScreen) + Box(modifier = modifier.fillMaxSize()) { + Navigator(screen = OnboardingScreen) + } } else { HomeScreen(modifier = modifier.fillMaxSize()) } diff --git a/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/DataSourcesPage.kt b/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/DataSourcesPage.kt index 1d7f08c8..6ec46042 100644 --- a/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/DataSourcesPage.kt +++ b/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/DataSourcesPage.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -75,6 +76,7 @@ fun NewsSourcesPage( .fillMaxWidth() .semantics { isTraversalGroup = true + testTag = "onboarding:datasources:content" } ) { allNewsDataSources.forEachIndexed { index, newsDataSource -> diff --git a/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/GamesPage.kt b/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/GamesPage.kt index a692c325..49e29b41 100644 --- a/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/GamesPage.kt +++ b/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/GamesPage.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription @@ -61,6 +62,7 @@ import androidx.compose.ui.semantics.performImeAction import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign @@ -218,7 +220,8 @@ fun SelectingFavouriteGamesPage( rows = StaggeredGridCells.Adaptive(minSize = 64.dp), modifier = Modifier .fillMaxWidth() - .height(236.dp), + .height(236.dp) + .testTag("onboarding:games:suggestedGames"), horizontalItemSpacing = 8.dp, verticalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -267,7 +270,7 @@ fun SelectingFavouriteGamesPage( } if (games.loadState.append is LoadStateError) { item { - LudiErrorBox(modifier = Modifier.fillMaxWidth()) + LudiErrorBox() } } } @@ -380,6 +383,7 @@ private fun GameTileScaffold( strings.games_page_game_off_state_desc(title ?: "") } this.selected = selected + testTag = if (id == null) "onboarding:games:gameLoading" else "onboarding:games:gameContent" }, elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), shape = MaterialTheme.shapes.small diff --git a/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/GenresPage.kt b/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/GenresPage.kt index 7bf866bf..1391f3b5 100644 --- a/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/GenresPage.kt +++ b/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/GenresPage.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription @@ -36,6 +37,7 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -92,7 +94,7 @@ fun GenresPage( when (allGenres) { is Result.Loading -> { Box( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().testTag("onboarding:genres:loadingWheel"), contentAlignment = Alignment.Center ) { CircularProgressIndicator() @@ -104,6 +106,7 @@ fun GenresPage( .fillMaxWidth() .semantics { isTraversalGroup = true + testTag = "onboarding:genres:content" }, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { diff --git a/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/OnboardingScreen.kt b/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/OnboardingScreen.kt index c950f7d1..50b51cf4 100644 --- a/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/OnboardingScreen.kt +++ b/shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/OnboardingScreen.kt @@ -62,6 +62,7 @@ import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import cafe.adriel.lyricist.LocalStrings @@ -348,6 +349,7 @@ fun AnimatedExtendedFab( OnboardingFABState.Continue -> strings.on_boarding_fab_state_continue_state_desc OnboardingFABState.Finish -> strings.on_boarding_fab_state_finish_state_desc } + testTag = "onboarding:fab" }, shape = RoundedCornerShape(50), containerColor = MaterialTheme.colorScheme.primary