Skip to content

Commit 20ba5e3

Browse files
authored
Merge pull request #16 from addhen/setupBaseProfile
Setup base profile
2 parents b02f45b + 32e3722 commit 20ba5e3

File tree

29 files changed

+1734
-27
lines changed

29 files changed

+1734
-27
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Credits: https://github.com/chrisbanes/tivi/blob/main/.github/workflows/baseline-profile.yml
2+
name: Baseline profile generation
3+
4+
on:
5+
# Every Sunday at 00:30 UTC
6+
schedule:
7+
- cron: '30 0 * * 0'
8+
workflow_dispatch:
9+
pull_request:
10+
paths:
11+
- 'kanalytics-viewer-base-profile/**'
12+
- '.github/workflows/baseline-profile.yml'
13+
jobs:
14+
baseline-profile:
15+
runs-on: ubuntu-latest
16+
timeout-minutes: 60
17+
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@v4
21+
with:
22+
# We need to use a Personal Access Token from an admin to be able to commit to main,
23+
# as it is a protected branch.
24+
# https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token#creating-a-fine-grained-personal-access-token
25+
token: ${{ secrets.OWNER_PAT_TOKEN }}
26+
- name: Setup
27+
uses: ./.github/actions/setup
28+
29+
- name: Enable KVM
30+
run: |
31+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
32+
sudo udevadm control --reload-rules
33+
sudo udevadm trigger --name-match=kvm
34+
35+
- name: Accept Android SDK licenses
36+
run: yes | $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager --licenses
37+
38+
# This allows us to build most of what we need without the emulator running
39+
# and using resources
40+
- name: Build app and benchmark
41+
run: ./gradlew assembleNonMinifiedRelease
42+
43+
- name: Clear Gradle Managed Devices
44+
run: ./gradlew cleanManagedDevices
45+
46+
- name: Run benchmark on Gradle Managed Device
47+
run: |
48+
./gradlew generateBaselineProfile \
49+
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" \
50+
-Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile \
51+
-Pandroid.experimental.testOptions.managedDevices.setupTimeoutMinutes=10 \
52+
--no-configuration-cache
53+
54+
# If we're on main branch, copy over the baseline profile and
55+
# commit it to the repository (if changed)
56+
- name: Commit baseline profile into main
57+
if: github.ref == 'refs/heads/main'
58+
run: |
59+
# If the baseline profile has changed, commit it
60+
if [[ $(git diff --stat kanalytics-viewer-base-profile/src) != '' ]]; then
61+
git config user.name github-actions
62+
git config user.email github-actions@github.com
63+
git add kanalytics-viewer-base-profile/src
64+
git commit -m "Update app baseline profile"
65+
git pull --rebase
66+
git push
67+
fi
68+
69+
- name: Upload reports
70+
if: always()
71+
uses: actions/upload-artifact@v4
72+
with:
73+
name: reports
74+
path: |
75+
**/build/reports/*

android-common-test/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/build

android-common-test/build.gradle.kts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright 2025, Addhen Ltd and the kanalytics project contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
5+
plugins {
6+
id("convention.plugin.android.library")
7+
id("convention.plugin.kotlin.android")
8+
}
9+
10+
android {
11+
namespace = "com.addhen.kanalytics.android.common.test"
12+
}
13+
14+
dependencies {
15+
// implementation(projects.kanalytics)
16+
implementation(projects.sample.android)
17+
api(libs.androidx.uiautomator)
18+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright 2025, Addhen Ltd and the kanalytics project contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package com.addhen.kanalytics.android.common.test
5+
6+
import android.os.SystemClock
7+
import android.util.Log
8+
import androidx.test.uiautomator.By
9+
import androidx.test.uiautomator.BySelector
10+
import androidx.test.uiautomator.Direction
11+
import androidx.test.uiautomator.SearchCondition
12+
import androidx.test.uiautomator.UiDevice
13+
import androidx.test.uiautomator.UiObject2
14+
import androidx.test.uiautomator.Until
15+
import kotlin.time.Duration
16+
import kotlin.time.Duration.Companion.seconds
17+
18+
object AppTestScenarios {
19+
20+
private const val TAG = "AppTestScenarios"
21+
22+
fun allScenarios(device: UiDevice) {
23+
Log.i(TAG, "Starting all scenarios")
24+
device.waitForIdle()
25+
26+
// Sample App triggers
27+
viewerAppStartUp(device)
28+
29+
// Event Viewer triggers
30+
device.testEventViewerApp() || return
31+
device.navigateToEventDetail()
32+
device.navigateFromEventDetailsToEventList()
33+
34+
// Settings screen triggers
35+
device.testSettings() || return
36+
device.launchSettings()
37+
device.scrollSettings()
38+
device.navigateFromSettingsToEventList()
39+
}
40+
41+
fun viewerAppStartUp(device: UiDevice) {
42+
Log.i(TAG, "Starting viewer app scenario")
43+
// Sample App triggers
44+
device.testSampleMainActivity() || return
45+
device.triggerEvent()
46+
device.launchViewerApp()
47+
}
48+
49+
fun UiDevice.testSampleMainActivity(): Boolean {
50+
waitForIdle()
51+
return true
52+
}
53+
54+
fun UiDevice.testEventViewerApp(): Boolean {
55+
// Keep waiting until an event list is displayed
56+
repeat(2) {
57+
if (wait(Until.hasObject(By.res("event_item_test_tag")), 5.seconds)) {
58+
return true
59+
}
60+
SystemClock.sleep(1.seconds.inWholeMilliseconds)
61+
waitForIdle()
62+
}
63+
return false
64+
}
65+
66+
fun UiDevice.testSettings(): Boolean {
67+
repeat(2) {
68+
if (wait(Until.hasObject(By.res("event_item_test_tag")), 5.seconds)) {
69+
return true
70+
}
71+
SystemClock.sleep(1.seconds.inWholeMilliseconds)
72+
waitForIdle()
73+
}
74+
return false
75+
}
76+
77+
fun UiDevice.launchSettings() {
78+
Log.i(TAG, "Launching Settings")
79+
waitForIdle()
80+
runAction(By.res("settings_test_tag")) { click() }
81+
waitForIdle()
82+
}
83+
84+
fun UiDevice.navigateFromEventDetailsToEventList() {
85+
Log.i(TAG, "Navigating to Event List Screen")
86+
waitForIdle()
87+
runAction(By.res("navigate_back_to_events_test_tag")) { click() }
88+
waitForIdle()
89+
}
90+
91+
fun UiDevice.navigateFromSettingsToEventList() {
92+
Log.i(TAG, "Navigating to Event List Screen")
93+
waitForIdle()
94+
runAction(By.res("navigate_back_from_settings_test_tag")) { click() }
95+
waitForIdle()
96+
}
97+
98+
fun UiDevice.triggerEvent() {
99+
waitForIdle()
100+
runAction(By.res("trigger_analytics_event_test_tag")) { click() }
101+
waitForIdle()
102+
}
103+
104+
fun UiDevice.launchViewerApp() {
105+
waitForIdle()
106+
runAction(By.res("event_viewer_test_tag")) { click() }
107+
waitForIdle()
108+
}
109+
110+
fun UiDevice.scrollSettings() {
111+
Log.i(TAG, "Scrolling Settings")
112+
waitForIdle()
113+
runAction(By.res("scroll_settings_test_tag")) {
114+
setGestureMargins(this)
115+
scroll(Direction.DOWN, 0.8f)
116+
}
117+
waitForIdle()
118+
}
119+
120+
fun UiDevice.navigateToEventDetail() {
121+
Log.i(TAG, "Navigating to Event Detail Screen")
122+
waitForIdle()
123+
runAction(By.res("event_item_test_tag")) { click() }
124+
waitForIdle()
125+
}
126+
127+
private fun UiDevice.runAction(
128+
selector: BySelector,
129+
maxRetries: Int = 6,
130+
action: UiObject2.() -> Unit,
131+
) {
132+
waitForObject(selector)
133+
134+
retry(maxRetries = maxRetries, delay = 1.seconds) {
135+
// Wait for idle, to avoid recompositions causing StaleObjectExceptions
136+
waitForIdle()
137+
138+
requireNotNull(findObject(selector)).action()
139+
}
140+
}
141+
142+
fun UiDevice.waitForObject(selector: BySelector, timeout: Duration = 5.seconds): UiObject2 {
143+
if (wait(Until.hasObject(selector), timeout)) {
144+
return findObject(selector)
145+
}
146+
error("Object with selector [$selector] not found")
147+
}
148+
149+
fun <R> UiDevice.wait(condition: SearchCondition<R>, timeout: Duration): R =
150+
wait(condition, timeout.inWholeMilliseconds)
151+
152+
private fun retry(maxRetries: Int, delay: Duration, block: () -> Unit) {
153+
repeat(maxRetries) { run ->
154+
val result = runCatching { block() }
155+
if (result.isSuccess) {
156+
return
157+
}
158+
if (run == maxRetries - 1) {
159+
result.getOrThrow()
160+
} else {
161+
SystemClock.sleep(delay.inWholeMilliseconds)
162+
}
163+
}
164+
}
165+
166+
private fun UiDevice.setGestureMargins(uiObject: UiObject2) {
167+
uiObject.setGestureMargins(
168+
(displayWidth * 0.1f).toInt(), // left
169+
(displayHeight * 0.2f).toInt(), // top
170+
(displayWidth * 0.1f).toInt(), // right
171+
(displayHeight * 0.2f).toInt(), // bottom
172+
)
173+
}
174+
}

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ plugins {
1919
alias(libs.plugins.compose.compiler) apply false
2020
alias(libs.plugins.dokka)
2121
alias(libs.plugins.jetbrains.kotlin.jvm) apply false
22+
alias(libs.plugins.androidx.baselineprofile) apply false
2223
}
2324

2425
tasks.register("printVersionName") {

convention-plugins/src/main/kotlin/com/addhen/gradle/convention/Android.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ fun Project.configureAndroid() {
2020
defaultConfig {
2121
minSdk = localMinSdk
2222
targetSdk = localTargetSdk
23+
24+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2325
}
2426

2527
compileOptions {

gradle/libs.versions.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ json-tree = "2.5.0-SNAPSHOT"
3131
kotlinx-atomicfu = "0.27.0"
3232
multiplatformsettings = "1.3.0"
3333
androidx-preference = "1.2.1"
34+
baselineprofile = "1.3.3"
35+
junit = "1.2.1"
36+
espressoCore = "3.6.1"
37+
uiautomator = "2.3.0"
38+
benchmarkMacroJunit4 = "1.3.3"
39+
profileinstaller = "1.4.1"
40+
rules = "1.6.1"
3441

3542
[libraries]
3643
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@@ -65,6 +72,12 @@ kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "k
6572
multiplatformsettings-core = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformsettings" }
6673
multiplatformsettings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformsettings" }
6774
androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" }
75+
androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" }
76+
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
77+
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
78+
androidx-benchmark-macro = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" }
79+
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
80+
androidx-rules = { group = "androidx.test", name = "rules", version.ref = "rules" }
6881

6982
[plugins]
7083
android-application = { id = "com.android.application", version.ref = "agp" }
@@ -84,3 +97,4 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi
8497
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
8598
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
8699
jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
100+
androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" }
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/build
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright 2025, Addhen Ltd and the kanalytics project contributors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
5+
import com.android.build.api.dsl.ManagedVirtualDevice
6+
7+
plugins {
8+
alias(libs.plugins.android.test)
9+
id("convention.plugin.kotlin.android")
10+
alias(libs.plugins.androidx.baselineprofile)
11+
}
12+
13+
android {
14+
namespace = "com.addhen.kanalytics.viewer.base.profile"
15+
16+
compileSdk = libs.versions.compileSdk.get().toInt()
17+
18+
defaultConfig {
19+
minSdk = 28
20+
21+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
22+
}
23+
24+
compileOptions {
25+
sourceCompatibility = JavaVersion.VERSION_11
26+
targetCompatibility = JavaVersion.VERSION_11
27+
}
28+
29+
@Suppress("UnstableApiUsage")
30+
testOptions {
31+
managedDevices {
32+
devices {
33+
create<ManagedVirtualDevice>("api34") {
34+
device = "Pixel 6"
35+
apiLevel = 34
36+
systemImageSource = "aosp"
37+
}
38+
}
39+
}
40+
}
41+
42+
targetProjectPath = ":sample:android"
43+
}
44+
45+
dependencies {
46+
implementation(projects.androidCommonTest)
47+
implementation(libs.androidx.test.junit)
48+
implementation(libs.androidx.benchmark.macro)
49+
implementation(libs.androidx.espresso.core)
50+
}
51+
52+
androidComponents {
53+
onVariants { v ->
54+
val artifactsLoader = v.artifacts.getBuiltArtifactsLoader()
55+
v.instrumentationRunnerArguments.put(
56+
"targetAppId",
57+
v.testedApks.map { artifactsLoader.load(it)?.applicationId },
58+
)
59+
}
60+
}
61+
62+
@Suppress("UnstableApiUsage")
63+
baselineProfile {
64+
managedDevices += "api34"
65+
useConnectedDevices = false
66+
enableEmulatorDisplay = true
67+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<manifest />

0 commit comments

Comments
 (0)