Skip to content

Commit 91442b1

Browse files
committed
Add test to generate screenshots for PlayStore
The reason for having these as instrumented tests is that we want them to include the rendered map. However, because that requires the GitHub secret containing the TILE_PROVIDER it can't be done from any old build. We can do it from a release, so do it there and upload the artifacts to that release. We can have both screenshots for the PlayStore, and ones for including in the jekyll docs build. We don't want the time to appear on the screenshots as that would result in endlessly changing screenshots, so trim them with imagemagick before committing. Any changes will result in the docs being rebuilt and using the new images.
1 parent 61a23f6 commit 91442b1

5 files changed

Lines changed: 362 additions & 0 deletions

File tree

.github/workflows/build-app.yaml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,47 @@ jobs:
125125
name: screenshot-previews
126126
path: ${{ env.main_project_module }}/src/debug/screenshotTest/reference/org/scottishtecharmy/soundscape/
127127

128+
- name: Enable KVM
129+
run: |
130+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
131+
sudo udevadm control --reload-rules
132+
sudo udevadm trigger --name-match=kvm
133+
134+
- name: Build instrumentation tests to generate screenshots for documentation
135+
id: documentation-screenshot-tests
136+
uses: reactivecircus/android-emulator-runner@v2
137+
continue-on-error: true
138+
with:
139+
api-level: 34
140+
target: default
141+
arch: x86_64
142+
profile: pixel_7
143+
script: |
144+
mkdir -p docs/documentationScreens
145+
adb logcat -c # clear logs
146+
adb shell settings put global policy_control immersive.status=* # Disable status bar at top of screen
147+
touch app/emulator.log # create log file
148+
chmod 777 app/emulator.log # allow writing to log file
149+
adb logcat >> app/emulator.log & # pipe all logcat messages into log file as a background process
150+
# Run only the DocumentationScreens tests
151+
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=org.scottishtecharmy.soundscape.DocumentationScreens
152+
# And then get the resulting screenshots from the emulator
153+
adb pull /storage/emulated/0/Android/data/org.scottishtecharmy.soundscape/files/Pictures/screenshots/homeScreen.png docs/documentationScreens/homeScreen.png
154+
adb pull /storage/emulated/0/Android/data/org.scottishtecharmy.soundscape/files/Pictures/screenshots/homeScreenWithRoute.png docs/documentationScreens/homeScreenWithRoute.png
155+
adb pull /storage/emulated/0/Android/data/org.scottishtecharmy.soundscape/files/Pictures/screenshots/routeDetails.png docs/documentationScreens/routeDetails.png
156+
adb pull /storage/emulated/0/Android/data/org.scottishtecharmy.soundscape/files/Pictures/screenshots/routeEdit.png docs/documentationScreens/routeEdit.png
157+
158+
- name: ImageMagick Action to trim off status bar from screenshots
159+
uses: jruipinto/ImageMagick-action@v1
160+
with:
161+
command: mogrify -path docs/documentationScreens -crop 1080x2130+0+140 docs/documentationScreens/*.*
162+
163+
- name: Commit new screenshots
164+
uses: stefanzweifel/git-auto-commit-action@v5
165+
with:
166+
commit_message: "New documentation screenshots"
167+
file_pattern: 'docs/documentationScreens/*.png'
168+
128169
# Run Build Project
129170
- name: Build gradle project
130171
env:

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ dependencies {
249249

250250
// Screenshots for tests
251251
//screenshotTestImplementation(libs.androidx.compose.ui.tooling)
252+
androidTestImplementation(libs.androidx.uiautomator)
252253

253254
// Protobuf
254255
implementation(libs.protobuf.kotlin.lite)
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
package org.scottishtecharmy.soundscape
2+
3+
import androidx.compose.foundation.layout.WindowInsets
4+
import androidx.compose.foundation.layout.safeDrawing
5+
import androidx.compose.foundation.layout.windowInsetsPadding
6+
import androidx.compose.ui.Modifier
7+
import androidx.compose.ui.test.junit4.createComposeRule
8+
import androidx.navigation.NavController
9+
import androidx.preference.PreferenceManager
10+
import androidx.test.platform.app.InstrumentationRegistry
11+
import kotlinx.coroutines.flow.MutableStateFlow
12+
import org.scottishtecharmy.soundscape.ui.theme.SoundscapeTheme
13+
import org.junit.Rule
14+
import org.junit.Test
15+
import org.scottishtecharmy.soundscape.MainActivity.Companion.ACCESSIBLE_MAP_KEY
16+
import org.scottishtecharmy.soundscape.database.local.model.MarkerEntity
17+
import org.scottishtecharmy.soundscape.database.local.model.RouteEntity
18+
import org.scottishtecharmy.soundscape.database.local.model.RouteWithMarkers
19+
import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt
20+
import org.scottishtecharmy.soundscape.screens.home.BottomButtonFunctions
21+
import org.scottishtecharmy.soundscape.screens.home.RouteFunctions
22+
import org.scottishtecharmy.soundscape.screens.home.StreetPreviewFunctions
23+
import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription
24+
import org.scottishtecharmy.soundscape.screens.home.home.Home
25+
import org.scottishtecharmy.soundscape.screens.home.placesnearby.PlacesNearbyUiState
26+
import org.scottishtecharmy.soundscape.screens.markers_routes.screens.addandeditroutescreen.AddAndEditRouteScreen
27+
import org.scottishtecharmy.soundscape.screens.markers_routes.screens.addandeditroutescreen.AddAndEditRouteUiState
28+
import org.scottishtecharmy.soundscape.screens.markers_routes.screens.routedetailsscreen.RouteDetailsScreen
29+
import org.scottishtecharmy.soundscape.screens.markers_routes.screens.routedetailsscreen.RouteDetailsUiState
30+
import org.scottishtecharmy.soundscape.services.RoutePlayerState
31+
import org.scottishtecharmy.soundscape.viewmodels.home.HomeState
32+
33+
// This is very helpful:
34+
// https://developer.android.com/develop/ui/compose/testing/testing-cheatsheet
35+
class DocumentationScreens {
36+
@get:Rule
37+
val composeTestRule = createComposeRule()
38+
39+
private val location = LngLatAlt(
40+
-4.3178027,
41+
55.9410791
42+
)
43+
44+
private val routeToShops = RouteWithMarkers(
45+
RouteEntity(
46+
name = "Route to shops",
47+
description = "",
48+
routeId = 1L
49+
),
50+
listOf(
51+
MarkerEntity(
52+
name = "Craigton Road",
53+
longitude = -4.3239319,
54+
latitude = 55.9446396,
55+
markerId = 1L
56+
),
57+
MarkerEntity(
58+
name = "Clober Road",
59+
longitude = -4.3210534,
60+
latitude = 55.9417227,
61+
markerId = 1L
62+
),
63+
MarkerEntity(
64+
name = "Douglas Street",
65+
longitude = -4.3194968,
66+
latitude = 55.9406974,
67+
markerId = 1L
68+
),
69+
MarkerEntity(
70+
name = "Underpass entry",
71+
longitude = -4.3175668,
72+
latitude = 55.9399973,
73+
markerId = 2L
74+
)
75+
)
76+
)
77+
78+
@Test
79+
fun homeScreen(){
80+
val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
81+
82+
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(targetContext)
83+
// Use accessible map
84+
sharedPreferences.edit().putBoolean(ACCESSIBLE_MAP_KEY, true).apply()
85+
86+
val themeStateFlow = MutableStateFlow(ThemeState())
87+
composeTestRule.setContent {
88+
SoundscapeTheme(themeStateFlow) {
89+
Home(
90+
state = HomeState(
91+
location = location,
92+
heading = 45.0F,
93+
94+
),
95+
onNavigate = {},
96+
preferences = sharedPreferences,
97+
onMapLongClick = { false},
98+
bottomButtonFunctions = BottomButtonFunctions(null),
99+
getCurrentLocationDescription = {
100+
LocationDescription(
101+
name = "Milngavie",
102+
location = location
103+
)
104+
},
105+
searchText = "",
106+
onToggleSearch = { },
107+
onSearchTextChange = { },
108+
rateSoundscape = { },
109+
routeFunctions = RouteFunctions(viewModel = null),
110+
streetPreviewFunctions = StreetPreviewFunctions(viewModel = null),
111+
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing)
112+
)
113+
}
114+
}
115+
116+
// Delay to allow the maps to load
117+
Thread.sleep(5000)
118+
119+
// Capture screenshot of the root composable
120+
ScreenshotUtils.captureAndSaveScreenshot(
121+
context = targetContext, // Use target context
122+
filename = "homeScreen"
123+
)
124+
}
125+
126+
@Test
127+
fun homeScreenWithRoute(){
128+
val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
129+
130+
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(targetContext)
131+
// Use accessible map
132+
sharedPreferences.edit().putBoolean(ACCESSIBLE_MAP_KEY, true).apply()
133+
134+
val routePlayerState = RoutePlayerState(
135+
routeData = routeToShops,
136+
currentWaypoint = 0
137+
)
138+
val themeStateFlow = MutableStateFlow(ThemeState())
139+
composeTestRule.setContent {
140+
SoundscapeTheme(themeStateFlow) {
141+
Home(
142+
state = HomeState(
143+
location = location,
144+
heading = 45.0F,
145+
currentRouteData = routePlayerState
146+
),
147+
onNavigate = {},
148+
preferences = sharedPreferences,
149+
onMapLongClick = { false},
150+
bottomButtonFunctions = BottomButtonFunctions(null),
151+
getCurrentLocationDescription = {
152+
LocationDescription(
153+
name = "Milngavie",
154+
location = location
155+
)
156+
},
157+
searchText = "",
158+
onToggleSearch = { },
159+
onSearchTextChange = { },
160+
rateSoundscape = { },
161+
routeFunctions = RouteFunctions(viewModel = null),
162+
streetPreviewFunctions = StreetPreviewFunctions(viewModel = null),
163+
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing)
164+
)
165+
}
166+
}
167+
168+
// Delay to allow the maps to load
169+
Thread.sleep(5000)
170+
171+
// Capture screenshot of the root composable
172+
ScreenshotUtils.captureAndSaveScreenshot(
173+
context = targetContext, // Use target context
174+
filename = "homeScreenWithRoute"
175+
)
176+
}
177+
178+
@Test
179+
fun routeDetailsScreen(){
180+
val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
181+
182+
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(targetContext)
183+
184+
// Use accessible map
185+
sharedPreferences.edit().putBoolean(ACCESSIBLE_MAP_KEY, true).apply()
186+
val themeStateFlow = MutableStateFlow(ThemeState())
187+
composeTestRule.setContent {
188+
SoundscapeTheme(themeStateFlow) {
189+
RouteDetailsScreen(
190+
navController = NavController(targetContext),
191+
routeId = 1,
192+
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing),
193+
uiState = RouteDetailsUiState(
194+
route = routeToShops
195+
),
196+
routePlayerState = RoutePlayerState(
197+
null,
198+
0
199+
),
200+
getRouteById = { },
201+
startRoute = { },
202+
stopRoute = { },
203+
shareRoute = { },
204+
clearErrorMessage = { },
205+
userLocation = location,
206+
heading = 45.0F
207+
)
208+
}
209+
}
210+
211+
// Delay to allow the maps to load
212+
Thread.sleep(5000)
213+
214+
// Capture screenshot of the root composable
215+
ScreenshotUtils.captureAndSaveScreenshot(
216+
context = targetContext, // Use target context
217+
filename = "routeDetails"
218+
)
219+
}
220+
@Test
221+
fun editScreen(){
222+
val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
223+
224+
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(targetContext)
225+
// Use accessible map
226+
sharedPreferences.edit().putBoolean(ACCESSIBLE_MAP_KEY, true).apply()
227+
228+
val route = routeToShops
229+
val members : MutableList<LocationDescription> = emptyList<LocationDescription>().toMutableList()
230+
for((index, marker) in route.markers.withIndex()) {
231+
members.add(
232+
LocationDescription(
233+
name = marker.name,
234+
location = LngLatAlt(marker.longitude, marker.latitude),
235+
orderId = index.toLong(),
236+
)
237+
)
238+
}
239+
240+
val uiState = AddAndEditRouteUiState(
241+
name = "To shops",
242+
description = "Route to shops",
243+
routeMembers = members,
244+
)
245+
246+
247+
val themeStateFlow = MutableStateFlow(ThemeState())
248+
composeTestRule.setContent {
249+
SoundscapeTheme(themeStateFlow) {
250+
AddAndEditRouteScreen(
251+
navController = NavController(targetContext),
252+
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing),
253+
uiState = uiState,
254+
userLocation = location,
255+
onClearErrorMessage = { },
256+
onResetDoneAction = { },
257+
onNameChange = { },
258+
onDescriptionChange = { },
259+
onDeleteRoute = { },
260+
onEditComplete = { },
261+
onClickFolder = { _, _ -> },
262+
onClickBack = { },
263+
onSelectLocation = { },
264+
createAndAddMarker = { _, _, _ -> },
265+
editRoute = true,
266+
routeObjectId = 1,
267+
placesNearbyUiState = PlacesNearbyUiState()
268+
)
269+
}
270+
}
271+
272+
// Delay to allow the maps to load
273+
Thread.sleep(5000)
274+
275+
// Capture screenshot of the root composable
276+
ScreenshotUtils.captureAndSaveScreenshot(
277+
context = targetContext, // Use target context
278+
filename = "routeEdit"
279+
)
280+
}
281+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package org.scottishtecharmy.soundscape
2+
3+
import android.content.Context
4+
import android.os.Environment
5+
import androidx.test.platform.app.InstrumentationRegistry
6+
import java.io.File
7+
import androidx.test.uiautomator.UiDevice
8+
9+
object ScreenshotUtils {
10+
11+
// Screenshots are stored in:
12+
// /storage/emulated/0/Android/data/org.scottishtecharmy.soundscape/files/Pictures/screenshots/$filename.png
13+
fun captureAndSaveScreenshot(
14+
context: Context,
15+
filename: String
16+
): String {
17+
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
18+
val screenshotDir = File(
19+
context.getExternalFilesDir(Environment.DIRECTORY_PICTURES),
20+
"screenshots"
21+
)
22+
if (!screenshotDir.exists()) {
23+
screenshotDir.mkdirs()
24+
}
25+
26+
val file = File(screenshotDir, "$filename.png")
27+
try {
28+
device.takeScreenshot(file)
29+
println("Screenshot saved to: ${file.absolutePath}")
30+
return file.absolutePath
31+
} catch (e: Exception) {
32+
System.err.println("Error saving screenshot: ${e.message}")
33+
e.printStackTrace()
34+
throw e // Re-throw to fail the test if saving fails
35+
}
36+
}
37+
}

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ screenshot = "0.0.1-alpha10"
6161
benchmarkCommon = "1.3.4"
6262
lifecycleRuntimeComposeAndroid = "2.9.1"
6363
lifecycleProcess = "2.9.1"
64+
uiautomator = "2.3.0"
6465

6566
[libraries]
6667
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
@@ -80,6 +81,7 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref =
8081
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomRuntime" }
8182
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
8283
androidx-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" }
84+
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" }
8385
converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "converterMoshi" }
8486
converter-protobuf = { module = "com.squareup.retrofit2:converter-protobuf", version.ref = "converterProtobuf" }
8587
converter-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "converterScalars" }

0 commit comments

Comments
 (0)