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