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() 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() @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() assertEquals(null, NativeSession(context, origin).load()) } private fun readApi(origin: String): NativeApi { val context = ApplicationProvider.getApplicationContext() 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() } }