feat(android): align native preview with measured PWA design
Android validation / emulator (37.0) (push) Failing after 3s
Android validation / emulator (35) (push) Failing after 4s
iOS validation / simulator (push) Successful in 59s
Android validation / build (push) Successful in 5m30s

This commit is contained in:
Yuvomi Mobile Contributors committed 2026-10-21 10:45:16 +02:00
1 parent bd0e4cc4fb
commit 7ac9882307
30 files changed
+2793 -750

No files matched your search

@@ -1,7 +1,10 @@
package community.yuvomi.mobile
import android.app.UiModeManager
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.SystemClock
import android.view.WindowManager
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.test.assertIsDisplayed
@@ -11,7 +14,6 @@ import androidx.compose.ui.test.isEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
@@ -33,6 +35,9 @@ class NativeUpstreamTest {
val reset = object : ExternalResource() {
override fun before() {
val context = ApplicationProvider.getApplicationContext<Context>()
if (Build.VERSION.SDK_INT >= 31) {
context.getSystemService(UiModeManager::class.java).setApplicationNightMode(UiModeManager.MODE_NIGHT_NO)
}
context.getSharedPreferences("native-server", Context.MODE_PRIVATE).edit().clear().commit()
context.getSharedPreferences("native-session", Context.MODE_PRIVATE).edit().clear().commit()
if (android.os.Build.VERSION.SDK_INT >= 37) {
@@ -72,10 +77,31 @@ class NativeUpstreamTest {
org.json.JSONObject().put("name", "Wochenmarkt"),
).getJSONObject("data").getLong("id")
seed.request("/shopping/$seedList/items", "POST", org.json.JSONObject().put("name", "Äpfel").put("quantity", "4"))
seed.request(
"/preferences",
"PUT",
org.json.JSONObject()
.put(
"dashboard_widgets",
org.json.JSONArray(
listOf("tasks", "calendar", "shopping", "notes").mapIndexed { order, id ->
org.json.JSONObject().put("id", id).put("visible", true).put("order", order).put("size", "1x1")
},
),
)
.put("mobile_nav_order", org.json.JSONArray(listOf("tasks", "kitchen", "notes")))
.put("timezone", "Europe/Berlin"),
)
seed.request("/auth/logout", "POST")
compose.onNodeWithTag("server-url").performTextInput(origin)
compose.onNodeWithTag("connect").performClick()
awaitTag("username")
captureFixtureScreen("login-light.png")
nightMode(true)
awaitTag("username")
captureFixtureScreen("login-dark.png")
nightMode(false)
awaitTag("username")
compose.onNodeWithTag("username").performTextInput("mobiletest")
compose.onNodeWithTag("password").performTextInput(password + "-invalid")
compose.onNodeWithTag("login").performScrollTo().performClick()
@@ -87,15 +113,23 @@ class NativeUpstreamTest {
compose.activityRule.scenario.onActivity {
assertEquals(null, ViewModelProvider(it)[NativeController::class.java].state.error)
}
captureFixtureDashboard()
captureFixtureScreen("native-dashboard.png")
captureFixtureScreen("dashboard-light.png")
captureModuleGallery("light")
nightMode(true)
awaitTag("native-dashboard")
captureFixtureScreen("dashboard-dark.png")
captureModuleGallery("dark")
nightMode(false)
awaitTag("native-dashboard")
compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.activityRule.scenario.recreate()
awaitTag("native-dashboard")
compose.onNodeWithTag("nav-TASKS").performClick()
awaitText("Hinzufügen")
compose.onNodeWithText("Hinzufügen").performClick()
awaitTag("add-entry")
compose.onNodeWithTag("add-entry").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native task regression")
compose.onNodeWithTag("entry-detail").performTextInput("Created with native controls")
compose.onNodeWithTag("entry-save").performClick()
@@ -103,8 +137,8 @@ class NativeUpstreamTest {
assertTrue(readApi(origin).request("/tasks").rows().any { it.text("title") == "Native task regression" })
compose.onNodeWithTag("nav-NOTES").performClick()
awaitText("Hinzufügen")
compose.onNodeWithText("Hinzufügen").performClick()
awaitTag("add-entry")
compose.onNodeWithTag("add-entry").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native note regression")
compose.onNodeWithTag("entry-detail").performTextInput("Not an embedded web page")
compose.onNodeWithTag("entry-save").performClick()
@@ -112,14 +146,14 @@ class NativeUpstreamTest {
assertTrue(readApi(origin).request("/notes").rows().any { it.text("content") == "Not an embedded web page" })
compose.onNodeWithTag("nav-SHOPPING").performClick()
awaitText("Neue Liste")
compose.onNodeWithText("Neue Liste").performClick()
awaitTag("add-list")
compose.onNodeWithTag("add-list").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native shopping regression")
compose.onNodeWithTag("entry-save").performClick()
awaitText("Native shopping regression")
compose.onNodeWithText("Native shopping regression").performClick()
awaitText("Hinzufügen")
compose.onNodeWithText("Hinzufügen").performClick()
awaitTag("add-entry")
compose.onNodeWithTag("add-entry").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native apples")
compose.onNodeWithTag("entry-detail").performTextInput("3")
compose.onNodeWithTag("entry-save").performClick()
@@ -157,15 +191,67 @@ class NativeUpstreamTest {
return api
}
private fun captureFixtureDashboard() {
private fun nightMode(dark: Boolean) {
check(Build.VERSION.SDK_INT >= 31) { "The reference gallery requires per-app night mode (API 31+)" }
compose.activityRule.scenario.onActivity {
it.getSystemService(UiModeManager::class.java).setApplicationNightMode(
if (dark) UiModeManager.MODE_NIGHT_YES else UiModeManager.MODE_NIGHT_NO,
)
}
compose.waitUntil(10_000) {
var matches = false
compose.activityRule.scenario.onActivity {
matches = (
it.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK ==
android.content.res.Configuration.UI_MODE_NIGHT_YES
) == dark
}
matches
}
compose.waitForIdle()
}
private fun captureModuleGallery(theme: String) {
listOf(NativePage.TASKS, NativePage.NOTES, NativePage.SHOPPING).forEach { page ->
compose.onNodeWithTag("nav-${page.name}").performClick()
compose.waitUntil(30_000) {
var ready = false
compose.activityRule.scenario.onActivity {
val state = ViewModelProvider(it)[NativeController::class.java].state
ready = !state.busy && state.page == page && state.rows.isNotEmpty()
}
ready
}
captureFixtureScreen("${page.name.lowercase()}-$theme.png")
}
compose.onNodeWithTag("nav-HOME").performClick()
awaitTag("native-dashboard")
}
private fun captureFixtureScreen(name: String) {
// Only this temporary-fixture test clears the screenshot restriction.
// Ordinary builds always retain FLAG_SECURE, including native login.
compose.activityRule.scenario.onActivity { it.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }
compose.activityRule.scenario.onActivity {
it.currentFocus?.clearFocus()
it.window.insetsController?.hide(android.view.WindowInsets.Type.ime())
it.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
try {
compose.waitForIdle()
compose.waitUntil(10_000) {
var hidden = false
compose.activityRule.scenario.onActivity {
hidden = it.window.decorView.rootWindowInsets?.isVisible(android.view.WindowInsets.Type.ime()) != true
}
hidden
}
// PixelCopy must run after the real window/inset animation, not only Compose idleness.
SystemClock.sleep(600)
// Capture the native window through PixelCopy, not a potentially stale
// secure compositor frame returned by UiAutomation immediately after clearing.
val image = compose.onRoot().captureToImage().asAndroidBitmap()
val node = compose.onNodeWithTag("native-screen")
val bounds = node.fetchSemanticsNode().boundsInRoot
val image = node.captureToImage().asAndroidBitmap()
try {
val first = image.getPixel(0, 0)
assertTrue(
@@ -175,9 +261,20 @@ class NativeUpstreamTest {
},
)
// AGP collects this test output before uninstalling the fixture APK.
PlatformTestStorageRegistry.getInstance().openOutputFile("native-dashboard.png").use {
PlatformTestStorageRegistry.getInstance().openOutputFile(name).use {
check(image.compress(Bitmap.CompressFormat.PNG, 100, it))
}
if (name == "native-dashboard.png") {
val resources = compose.activity.resources
val metadata = org.json.JSONObject()
.put("widthPx", image.width).put("heightPx", image.height)
.put("density", resources.displayMetrics.density).put("fontScale", resources.configuration.fontScale)
.put("sdk", Build.VERSION.SDK_INT)
.put("boundsInRoot", org.json.JSONArray(listOf(bounds.left, bounds.top, bounds.right, bounds.bottom)))
PlatformTestStorageRegistry.getInstance().openOutputFile("viewport.json").use {
it.write(metadata.toString(2).toByteArray())
}
}
} finally {
image.recycle()
}