diff --git a/.gitea/workflows/android-screenshots.yml b/.gitea/workflows/android-screenshots.yml new file mode 100644 index 0000000..a548aa8 --- /dev/null +++ b/.gitea/workflows/android-screenshots.yml @@ -0,0 +1,57 @@ +# Android client screenshots for the Play listing / marketing. Roborazzi renders the real Compose +# UI with mock state on the host JVM via Robolectric — NO emulator, GPU, KVM, host, or JNI core +# (`-PskipRustBuild` skips the cargo-ndk native build). The Android analogue of apple.yml's +# `screenshots` job, gated to STABLE RELEASE tags only. Standalone + best-effort: a failure here +# reds nothing else. PNGs land as a 30-day artifact; not committed or published. +name: android-screenshots + +on: + push: + tags: ["v*"] + workflow_dispatch: + +jobs: + screenshots: + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: JDK 21 (AGP 9.2 + Robolectric's SDK-36 android-all jar both want 17–21) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Android SDK + uses: android-actions/setup-android@v3 + + # No NDK/CMake — the screenshot unit tests are pure JVM. compileSdk 37 auto-downloads via AGP + # if the platform channel lacks it (same note as android.yml). + - name: platform-tools + platform 36 + build-tools + run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" + + - name: Cache (gradle) + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: android-screenshots-${{ hashFiles('clients/android/**/*.gradle.kts') }} + restore-keys: android-screenshots- + + # Roborazzi renders Compose on the JVM (Robolectric Native Graphics). `-PskipRustBuild` keeps + # the cargo-ndk native build out of the graph — the tests never load libpunktfunk_android.so. + - name: Capture screenshots (Roborazzi) + working-directory: clients/android + run: ./gradlew :app:testDebugUnitTest -PskipRustBuild --stacktrace + + - name: Upload screenshots + if: always() + # v3: Gitea's API rejects upload-artifact@v4 (see apple.yml). Download is a zip. + uses: actions/upload-artifact@v3 + with: + name: punktfunk-android-screenshots + path: clients/android/app/build/outputs/roborazzi + retention-days: 30 diff --git a/clients/android/app/build.gradle.kts b/clients/android/app/build.gradle.kts index 3d48a2c..a732f57 100644 --- a/clients/android/app/build.gradle.kts +++ b/clients/android/app/build.gradle.kts @@ -62,6 +62,10 @@ android { buildFeatures { compose = true } + // Roborazzi/Robolectric render Compose on the host JVM (the CI screenshot harness) and need the + // merged Android resources + the app's manifest/theme available to the unit tests. + testOptions { unitTests { isIncludeAndroidResources = true } } + compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 @@ -99,4 +103,21 @@ dependencies { // Android TV components (we target phone + TV) land in the TV-UI milestone: // implementation("androidx.tv:tv-material:1.1.0") // The manifest already declares leanback so the scaffold installs on TV. + + // --- CI screenshot harness (Roborazzi on the JVM via Robolectric — no emulator/GPU). The + // screenshot tests render the real Compose UI with mock state; never load the JNI core, so the + // job runs `:app:testDebugUnitTest -PskipRustBuild` (see kit/build.gradle.kts). --- + testImplementation(composeBom) + testImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host + testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.16.1") + testImplementation("io.github.takahirom.roborazzi:roborazzi:1.64.0") + testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.64.0") +} + +// Record (write) the screenshots when the unit tests run. These tests exist to GENERATE marketing +// images, not to diff goldens, so always capture rather than verify. +tasks.withType().configureEach { + systemProperty("roborazzi.test.record", "true") } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 808139c..a9a6fd3 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -319,7 +319,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { * `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped]`. */ @Composable -private fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) { +internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) { if (s.size < 10) return val w = s[6].toInt() val h = s[7].toInt() diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Theme.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Theme.kt index 9424209..9e2687e 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Theme.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Theme.kt @@ -10,7 +10,9 @@ import androidx.compose.ui.platform.LocalContext // punktfunk brand violets (from the app icon: #6C5BF3 / #A79FF8 / #D2C9FB on a #16132A indigo). // Used as the fallback dark scheme on pre-Android-12 devices; on 12+ we defer to Material You. -private val BrandDark = darkColorScheme( +// `internal` (not private) so the CI screenshot tests can force the deterministic brand palette — +// Material You dynamic colour has no wallpaper to seed from under the Robolectric JVM renderer. +internal val BrandDark = darkColorScheme( primary = Color(0xFFA79FF8), onPrimary = Color(0xFF1B1442), primaryContainer = Color(0xFF4C3FB3), diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt new file mode 100644 index 0000000..5278f48 --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -0,0 +1,74 @@ +package io.unom.punktfunk.screenshots + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import com.github.takahirom.roborazzi.captureRoboImage +import com.github.takahirom.roborazzi.captureScreenRoboImage +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * App-store / marketing screenshots of the native Android client, rendered on the JVM by Roborazzi + * (Robolectric Native Graphics) — no emulator, GPU, host, or JNI core. The scenes (ShotScenes.kt) + * render the REAL Compose UI with mock state. + * + * `sdk = [36]` is mandatory: Robolectric ships android-all jars only up to API 36 (Android 16), and + * the app's compileSdk is 37. PNGs land in build/outputs/roborazzi/. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi") +class ScreenshotTest { + @get:Rule + val compose = createAndroidComposeRule() + + private val out = "build/outputs/roborazzi" + + // Pausing the animation clock before composing (then advancing once past the entrance animation + // and freezing) is what makes a text-field-bearing scene capturable: a focused field blinks its + // cursor via an infinite animation that otherwise keeps Compose perpetually "busy", so + // setContent's wait-for-idle never returns. Frozen, the capture is also deterministic. + + /** Full-screen content scenes: the compose root fills the device, so a root capture is the shot. */ + private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) { + compose.mainClock.autoAdvance = false + compose.setContent { ShotTheme(content) } + compose.mainClock.advanceTimeBy(800) + compose.onRoot().captureRoboImage("$out/phone-$name.png") + } + + /** Dialog scenes: the AlertDialog is a separate window, so capture the whole screen (all windows). */ + private fun shootScreen(name: String, content: @androidx.compose.runtime.Composable () -> Unit) { + compose.mainClock.autoAdvance = false + compose.setContent { ShotTheme(content) } + compose.mainClock.advanceTimeBy(800) + captureScreenRoboImage("$out/phone-$name.png") + } + + @Test + fun hosts() = shootRoot("hosts") { HostsScene() } + + @Test + fun settings() = shootRoot("settings") { SettingsScene() } + + @Test + @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive + fun stream() = shootRoot("stream") { StreamScene() } + + @Test + fun trust() = shootScreen("trust") { + HostsScene() + TrustDialog() + } + + @Test + fun pair() = shootScreen("pair") { + HostsScene() + PairDialog() + } +} diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt new file mode 100644 index 0000000..df0b7ae --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -0,0 +1,195 @@ +package io.unom.punktfunk.screenshots + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.unom.punktfunk.BrandDark +import io.unom.punktfunk.Settings +import io.unom.punktfunk.SettingsScreen +import io.unom.punktfunk.StatsOverlay +import io.unom.punktfunk.components.HostCard +import io.unom.punktfunk.components.SectionLabel +import io.unom.punktfunk.models.HostStatus + +// The CI screenshot scenes: the REAL app composables, fed embedded mock state, under the forced +// brand palette (Material You has no wallpaper to seed from on the JVM). The stream-video surface +// and ConnectScreen/App are intentionally absent — they require the live JNI core / a session. + +/** Forces the deterministic punktfunk brand scheme (see Theme.kt) instead of dynamic colour. */ +@Composable +internal fun ShotTheme(content: @Composable () -> Unit) { + MaterialTheme(colorScheme = BrandDark, content = content) +} + +private data class MockHost(val name: String, val address: String, val status: HostStatus) + +private val SAVED = listOf( + MockHost("Living Room PC", "192.168.1.42:9777", HostStatus.PAIRED), + MockHost("Office", "192.168.1.50:9777", HostStatus.TOFU), +) +private val DISCOVERED = listOf( + MockHost("studio-deck", "192.168.1.61:9777", HostStatus.PAIRING), + MockHost("HTPC", "192.168.1.70:9777", HostStatus.TOFU), +) + +/** The connect screen's host grid, reconstructed from the real HostCard/SectionLabel components. */ +@Composable +internal fun HostsScene() { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 160.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + Spacer(Modifier.height(8.dp)) + Text("Punktfunk", style = MaterialTheme.typography.headlineLarge) + Text( + "stream a remote desktop", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + } + } + item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") } + items(SAVED) { h -> + HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onRename = {}) + } + item(span = { GridItemSpan(maxLineSpan) }) { + Spacer(Modifier.height(12.dp)) + SectionLabel("Discovered on the network") + } + items(DISCOVERED) { h -> + HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = null) + } + } + } +} + +/** The real SettingsScreen, fed a representative non-default Settings. */ +@Composable +internal fun SettingsScene() { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + SettingsScreen( + initial = Settings( + width = 1920, + height = 1080, + hz = 120, + bitrateKbps = 50_000, + compositor = 1, + gamepad = 2, + micEnabled = true, + statsHudEnabled = true, + trackpadMode = true, + ), + onChange = {}, + onBack = {}, + ) + } +} + +/** The real TOFU AlertDialog (mirrors ConnectScreen's PendingTrust.Kind.TRUST_NEW), shown over the host grid. */ +@Composable +internal fun TrustDialog() { + AlertDialog( + onDismissRequest = {}, + title = { Text("Trust this host?") }, + text = { + Column { + Text("First connection to 192.168.1.61:9777.") + Text("Fingerprint 9f8e7d6c5b4a3928…") + Text( + "This host allows trust-on-first-use, but that can't tell an impostor " + + "from the real host. Pairing with a PIN is stronger — it proves both sides.", + ) + } + }, + confirmButton = { TextButton({}) { Text("Trust (TOFU)") } }, + dismissButton = { TextButton({}) { Text("Pair with PIN…") } }, + ) +} + +/** The PIN-pairing AlertDialog (mirrors ConnectScreen's PendingTrust.Kind.PAIR). The live screen + * uses OutlinedTextFields, but a TextField inside a Dialog window never reaches idle under + * Robolectric (its focus/cursor machinery animates forever) — so the PIN is shown as a static + * display here, which also reads better in a marketing shot. */ +@Composable +internal fun PairDialog() { + AlertDialog( + onDismissRequest = {}, + title = { Text("Pair with PIN") }, + text = { + Column { + Text("Enter the 4-digit PIN shown on the host.") + Spacer(Modifier.height(16.dp)) + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + "4 8 2 7", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + ) + } + Spacer(Modifier.height(12.dp)) + Text( + "This device: Pixel 9 Pro", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { TextButton({}) { Text("Pair") } }, + dismissButton = { TextButton({}) { Text("Cancel") } }, + ) +} + +/** The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient. */ +@Composable +internal fun StreamScene() { + Box( + Modifier + .fillMaxSize() + .background( + Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))), + ), + ) { + // [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped] + StatsOverlay( + doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0), + Modifier.align(Alignment.TopStart).padding(12.dp), + ) + } +} diff --git a/clients/android/kit/build.gradle.kts b/clients/android/kit/build.gradle.kts index 930318c..6f8e82c 100644 --- a/clients/android/kit/build.gradle.kts +++ b/clients/android/kit/build.gradle.kts @@ -99,6 +99,12 @@ val cargoNdkDebug = registerCargoNdk("cargoNdkDebug", release = false) val cargoNdkRelease = registerCargoNdk("cargoNdkRelease", release = true) afterEvaluate { - tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) } - tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) } + // `-PskipRustBuild` skips the cargo-ndk native build — for JVM-only tasks (the Roborazzi + // screenshot unit tests render Compose on the JVM and never load libpunktfunk_android.so), so + // CI/local screenshot runs don't need the Rust toolchain or NDK. The native build stays wired + // for every normal APK/AAR build. + if (!project.hasProperty("skipRustBuild")) { + tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) } + tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) } + } }