Files
Yuvomi-App/android/app/src/androidTestNativeIntegration/java/community/yuvomi/mobile/NativeUpstreamTest.kt
T
Yuvomi Mobile Contributors 7ac9882307
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
feat(android): align native preview with measured PWA design
2026-10-21 10:45:16 +02:00

305 lines
14 KiB
Kotlin

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
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.hasText
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.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.platform.io.PlatformTestStorageRegistry
import java.time.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.ExternalResource
/** Drives native semantics and real upstream API calls; never evaluates JavaScript. */
class NativeUpstreamTest {
@get:Rule(order = 0)
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) {
InstrumentationRegistry.getInstrumentation().uiAutomation.grantRuntimePermission(
context.packageName,
android.Manifest.permission.ACCESS_LOCAL_NETWORK,
)
}
}
}
@get:Rule(order = 1)
val compose = createAndroidComposeRule<MainActivity>()
@Test fun nativeLoginDashboardTaskNoteShoppingAndLogoutAgainstRealServer() {
val arguments = InstrumentationRegistry.getArguments()
val origin = requireNotNull(arguments.getString("server"))
val password = requireNotNull(arguments.getString("password"))
val seed = NativeApi(ServerAddress.parse(origin), NativeCookies(ServerAddress.parse(origin)), {})
seed.request("/auth/login", "POST", org.json.JSONObject().put("username", "mobiletest").put("password", password))
seed.request(
"/tasks",
"POST",
org.json.JSONObject().put(
"title",
"Gemeinsam den Alltag planen",
).put("priority", "high").put("due_date", LocalDate.now().toString()),
)
seed.request(
"/notes",
"POST",
org.json.JSONObject().put("title", "Willkommen zuhause").put("content", "Native Oberfläche, eigener Server.").put("pinned", 1),
)
val seedList = seed.request(
"/shopping",
"POST",
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()
awaitTag("auth-error")
compose.onNodeWithText("Benutzername oder Passwort wurden vom Server nicht akzeptiert.").assertIsDisplayed()
compose.onNodeWithTag("password").performTextInput(password)
compose.onNodeWithTag("login").performScrollTo().performClick()
awaitTag("native-dashboard")
compose.activityRule.scenario.onActivity {
assertEquals(null, ViewModelProvider(it)[NativeController::class.java].state.error)
}
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()
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()
awaitText("Native task regression")
assertTrue(readApi(origin).request("/tasks").rows().any { it.text("title") == "Native task regression" })
compose.onNodeWithTag("nav-NOTES").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()
awaitText("Native note regression")
assertTrue(readApi(origin).request("/notes").rows().any { it.text("content") == "Not an embedded web page" })
compose.onNodeWithTag("nav-SHOPPING").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()
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()
awaitText("Native apples")
val api = readApi(origin)
val list = api.request("/shopping").rows().first { it.text("name") == "Native shopping regression" }
assertEquals(
"3",
api.request("/shopping/${list.getLong("id")}/items").rows().first {
it.text("name") == "Native apples"
}.text("quantity"),
)
// Verify the same native transport rejects a cross-origin OIDC redirect.
try {
api.request("/auth/oidc/start")
throw AssertionError("Native API followed an authentication redirect")
} catch (error: NativeApiError) {
assertEquals(302, error.status)
}
compose.onNodeWithTag("nav-MORE").performClick()
awaitText("Abmelden")
compose.onNodeWithText("Abmelden").performScrollTo().performClick()
awaitTag("username")
val context = ApplicationProvider.getApplicationContext<Context>()
assertEquals(null, NativeSession(context, origin).load())
}
private fun readApi(origin: String): NativeApi {
val context = ApplicationProvider.getApplicationContext<Context>()
val vault = NativeSession(context, origin)
val api = NativeApi(ServerAddress.parse(origin), NativeCookies(ServerAddress.parse(origin), vault.load()), {})
api.request("/auth/me")
return api
}
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.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 node = compose.onNodeWithTag("native-screen")
val bounds = node.fetchSemanticsNode().boundsInRoot
val image = node.captureToImage().asAndroidBitmap()
try {
val first = image.getPixel(0, 0)
assertTrue(
"Native fixture screenshot is blank",
(0 until image.height step 8).any { y ->
(0 until image.width step 8).any { x -> image.getPixel(x, y) != first }
},
)
// AGP collects this test output before uninstalling the fixture APK.
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()
}
} finally {
compose.activityRule.scenario.onActivity { it.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) }
}
}
private fun awaitTag(tag: String) {
try {
compose.waitUntil(30_000) { compose.onAllNodes(androidx.compose.ui.test.hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty() }
} catch (error: Exception) {
var diagnostic = ""
compose.activityRule.scenario.onActivity {
val state = ViewModelProvider(it)[NativeController::class.java].state
diagnostic = "busy=${state.busy}, signedIn=${state.user != null}, error=${state.error}"
}
throw AssertionError("Native screen '$tag' not reached: $diagnostic", error)
}
compose.onNodeWithTag(tag).assertIsDisplayed()
}
private fun awaitText(text: String) {
compose.waitUntil(30_000) { compose.onAllNodes(hasText(text) and isEnabled()).fetchSemanticsNodes().isNotEmpty() }
compose.onNodeWithText(text).assertIsDisplayed()
}
}