Compare commits

...
Author SHA256 Message Date
Yuvomi Mobile Contributors e0938df256 docs: record verified PWA design preview and recovered Apple validation
Android validation / emulator (35) (push) Failing after 4s
Android validation / emulator (37.0) (push) Failing after 4s
iOS validation / simulator (push) Successful in 58s
Android validation / build (push) Successful in 5m15s
2026-10-21 10:51:40 +02:00
Yuvomi Mobile Contributors 7ac9882307 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
2026-10-21 10:45:16 +02:00
Yuvomi Mobile Contributors bd0e4cc4fb docs: record verified Android login fix artifact and regression results
Android validation / emulator (35) (push) Failing after 4s
Android validation / emulator (37.0) (push) Failing after 4s
iOS validation / simulator (push) Successful in 1m4s
Android validation / build (push) Successful in 5m45s
2026-10-21 10:00:28 +02:00
Yuvomi Mobile Contributors 4b483c20e3 fix(android): retain HTTPS sessions with upstream default cookie flags
Android validation / emulator (37.0) (push) Failing after 3s
Android validation / emulator (35) (push) Failing after 9s
iOS validation / simulator (push) Successful in 1m48s
Android validation / build (push) Successful in 5m10s
Reproduce browser-success/native-401 with unmodified upstream SESSION_SECURE=false behind HTTPS. Keep cookies in the existing HTTPS-only origin-bound encrypted jar regardless of the server Secure attribute; preserve TLS, redirect, expiry and origin protections. Distinguish credential/2FA rejection from missing/rejected session handoff and clear stale login errors on retry. Verify 13 JVM tests plus the 7-test real-server suite in both Secure/default-cookie modes, including bad-password then successful native login. Bump Android preview to 0.2.1/code 3; extend CI and document the defect without claiming access to the user's production server.
2026-10-21 09:53:55 +02:00
Yuvomi Mobile Contributors 129eac675c docs: record confirmed CoreSimulator runner blocker
Android validation / emulator (35) (push) Failing after 4s
Android validation / emulator (37.0) (push) Failing after 9s
iOS validation / simulator (push) Failing after 1m5s
Android validation / build (push) Successful in 5m16s
Follow-up Mac job 1066 passed Node/checkout/Xcode/Swift lookup, then correctly failed the bounded simctl device-list probe after sixty seconds. Record the exact administrator-owned blocker without claiming Apple app validation.
2026-10-21 09:38:59 +02:00
Yuvomi Mobile Contributors 92dbd0e06d fix(ci): bound stalled Apple probes and record verified native APK
Android validation / emulator (35) (push) Failing after 4s
Android validation / emulator (37.0) (push) Failing after 9s
iOS validation / simulator (push) Failing after 1m5s
Android validation / build (push) Canceled after 2m0s
Mac Node bootstrap and checkout now pass, but Apple-tool preflight stalled after Xcode version output for twenty minutes. Add per-command deadlines and diagnostics without changing licenses/host settings; fourteen preflight/probe regressions pass. Record successful Android build/upload, downloaded APK signature/version and emulator install/launch, and unchanged missing-KVM matrix failures. No Apple app success is claimed.
2026-10-21 09:37:12 +02:00
41 changed files with 3252 additions and 792 deletions

No files matched your search

+2 -1
View File
@@ -98,8 +98,9 @@ jobs:
disable-animations: true disable-animations: true
script: | script: |
bash android/gradlew -p android connectedDebugAndroidTest bash android/gradlew -p android connectedDebugAndroidTest
python3 scripts/verify-test-results.py android/app/build/outputs/androidTest-results/connected/debug --minimum 6 python3 scripts/verify-test-results.py android/app/build/outputs/androidTest-results/connected/debug --minimum 9
python3 scripts/test-integration.py "$YUVOMI_UPSTREAM" --android python3 scripts/test-integration.py "$YUVOMI_UPSTREAM" --android
python3 scripts/test-integration.py "$YUVOMI_UPSTREAM" --android --cookie-without-secure
- uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de - uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de
if: always() if: always()
with: with:
+26
View File
@@ -2,6 +2,30 @@
## [Unreleased] ## [Unreleased]
### Android 0.2.2 native preview (code 4)
- Replace the initial Material-style slice with native PWA-derived typography,
light/dark colors, module icons/seals, compact cards, list rows, capsule
navigation and bottom-sheet editors. Remove the duplicate overview heading.
- Read personal dashboard/favorite preferences, select the first shopping list,
and save supported widget visibility without deleting unsupported config.
- Add real-PWA reference capture and a native ten-view light/dark screenshot
gallery with measured content viewport; browser automation is tooling only.
- Preserve the confirmed HTTPS login/session fix. This is an Android design pass,
**not accepted 1:1 parity**; iOS and remaining modules are unchanged/incomplete.
### Android 0.2.1 native preview (code 3)
- Fix login against upstream-default session cookies without `Secure`: Android
now retains them in its origin-bound HTTPS-only jar instead of losing the
authenticated session. HTTP, cross-origin redirects and invalid TLS remain denied.
- Distinguish credential/code rejection from a missing/rejected native session;
clear a previous login error after a successful retry.
- Exercise both upstream cookie configurations over real HTTPS, including a wrong
password followed by a successful native login. See `docs/login-troubleshooting.md`.
### Initial native preview
- Begin the genuine native rebuild: Compose/SwiftUI screens, direct HTTPS API, - Begin the genuine native rebuild: Compose/SwiftUI screens, direct HTTPS API,
protected native session storage and initial auth/dashboard/task/note/shopping protected native session storage and initial auth/dashboard/task/note/shopping
flows. Archive the rejected WebView renderer and its tests. Full PWA parity is flows. Archive the rejected WebView renderer and its tests. Full PWA parity is
@@ -10,6 +34,8 @@
- Gitea workflows for the maintainer's Ubuntu pool and native M4 Pro runner, - Gitea workflows for the maintainer's Ubuntu pool and native M4 Pro runner,
explicit SDK/KVM prerequisites, SHA-256 checkout and unsigned-only CI release builds. explicit SDK/KVM prerequisites, SHA-256 checkout and unsigned-only CI release builds.
### Archived browser prototype (not shipped)
- Native Android HTTPS onboarding, origin-bound browsing, session persistence, - Native Android HTTPS onboarding, origin-bound browsing, session persistence,
recovery, server erasure, camera/document selection, downloads and blob exports. recovery, server erasure, camera/document selection, downloads and blob exports.
- SwiftUI/WKWebView iOS implementation, isolated web sign-in, native settings, - SwiftUI/WKWebView iOS implementation, isolated web sign-in, native settings,
+1
View File
@@ -21,6 +21,7 @@ import a browser/WebView session; sign in again after upgrading the prototype.
- [Architecture](docs/architecture.md) - [Architecture](docs/architecture.md)
- [Native preview delivery report](docs/native-delivery-report.md) - [Native preview delivery report](docs/native-delivery-report.md)
- [PWA design comparison and remaining differences](docs/native-design.md)
- [Android build](docs/build-android.md) / [iOS build](docs/build-ios.md) - [Android build](docs/build-android.md) / [iOS build](docs/build-ios.md)
- [Compatibility and authentication](docs/server-compatibility.md) - [Compatibility and authentication](docs/server-compatibility.md)
- [Release process](docs/release-process.md) - [Release process](docs/release-process.md)
+5 -2
View File
@@ -16,8 +16,11 @@ server vulnerabilities belong in its private reporting channel.
- Passwords and second-factor codes exist transiently for the API request; they - Passwords and second-factor codes exist transiently for the API request; they
are not saved to preferences, keychains, logs, artifacts or application state are not saved to preferences, keychains, logs, artifacts or application state
restoration. Server cookies, not passwords, persist the session. restoration. Server cookies, not passwords, persist the session.
- Android persists allow-listed Secure cookies with their absolute expiry in - Android treats every allow-listed native cookie as HTTPS-only, bound to the
AES-GCM ciphertext backed by AndroidKeyStore, with the origin authenticated as configured origin/port. This also applies when upstream omits the `Secure`
attribute (`SESSION_SECURE=false` is upstream's default); no HTTP transport is
enabled. Persistent cookies retain their absolute expiry in AES-GCM ciphertext
backed by AndroidKeyStore, with the origin authenticated as
additional data. iOS uses a private ephemeral URLSession jar and origin-scoped additional data. iOS uses a private ephemeral URLSession jar and origin-scoped
Keychain items protected with WhenUnlockedThisDeviceOnly. Keychain items protected with WhenUnlockedThisDeviceOnly.
- CSRF tokens follow the upstream API protocol. Only an explicit `csrf_invalid` - CSRF tokens follow the upstream API protocol. Only an explicit `csrf_invalid`
+4
View File
@@ -6,6 +6,10 @@ Native design-token mappings follow upstream DESIGN.md/styles. The native
three-circle brand mark is redrawn from upstream's published brand geometry and three-circle brand mark is redrawn from upstream's published brand geometry and
colors (`public/utils/brand-mark.js` and `docs/logo.svg`); its MIT attribution is colors (`public/utils/brand-mark.js` and `docs/logo.svg`); its MIT attribution is
preserved below. No affiliation or trademark ownership is implied. preserved below. No affiliation or trademark ownership is implied.
Android's `PwaIcons.kt` also adapts the module paths from upstream
`public/nav-icons.js`; the native palette, spacing and typography follow its
`public/styles/{tokens,typography,layout,dashboard,auth,tasks,notes,shopping}.css`.
The reference browser tooling is development-only and is not shipped in the apps.
Upstream Yuvomi: https://github.com/ulsklyc/yuvomi Upstream Yuvomi: https://github.com/ulsklyc/yuvomi
+2 -2
View File
@@ -10,8 +10,8 @@ android {
applicationId = "community.yuvomi.mobile" applicationId = "community.yuvomi.mobile"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = 2 versionCode = 4
versionName = "0.2.0-native-preview" versionName = "0.2.2-native-preview"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
@@ -0,0 +1,59 @@
package community.yuvomi.mobile
import androidx.activity.compose.setContent
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.json.JSONObject
import org.junit.Rule
import org.junit.Test
class PwaComponentsTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
private fun page(state: NativeState) {
compose.activityRule.scenario.onActivity { activity ->
activity.setContent {
PwaTheme {
NativeCollection(state, {}, {}, {}, {}, {}, {}, {}, {})
}
}
}
}
@Test fun readOnlyTaskCannotBeToggledOrEdited() {
page(
NativeState(
page = NativePage.TASKS,
permissions = JSONObject("""{"modules":{"tasks":"read"}}"""),
rows = listOf(JSONObject("""{"id":1,"title":"Nur lesen","status":"open","category":"misc"}""")),
),
)
compose.onNodeWithContentDescription("Nur lesen").assertIsNotEnabled()
compose.onNodeWithContentDescription("Weitere Aktionen").assertDoesNotExist()
compose.onNodeWithText("SONSTIGES").assertIsDisplayed()
}
@Test fun emptyReadOnlyPageDoesNotAdvertiseCreation() {
page(NativeState(page = NativePage.NOTES, permissions = JSONObject("""{"modules":{"notes":"read"}}""")))
compose.onNodeWithText("Du hast hier Lesezugriff.").assertIsDisplayed()
compose.onNodeWithText("Neue Einträge erstellst du mit +.").assertDoesNotExist()
}
@Test fun taskFilterShowsDoneEntriesWithoutChangingServerState() {
page(
NativeState(
page = NativePage.TASKS,
rows = listOf(JSONObject("""{"id":1,"title":"Erledigte Aufgabe","status":"done","category":"misc"}""")),
),
)
compose.onNodeWithText("Erledigte Aufgabe").assertDoesNotExist()
compose.onNodeWithContentDescription("Filter").performClick()
compose.onNodeWithText("Erledigt").performClick()
compose.onNodeWithText("Erledigte Aufgabe").assertIsDisplayed()
}
}
@@ -1,7 +1,10 @@
package community.yuvomi.mobile package community.yuvomi.mobile
import android.app.UiModeManager
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build
import android.os.SystemClock
import android.view.WindowManager import android.view.WindowManager
import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.test.assertIsDisplayed 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.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
@@ -33,6 +35,9 @@ class NativeUpstreamTest {
val reset = object : ExternalResource() { val reset = object : ExternalResource() {
override fun before() { override fun before() {
val context = ApplicationProvider.getApplicationContext<Context>() 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-server", Context.MODE_PRIVATE).edit().clear().commit()
context.getSharedPreferences("native-session", Context.MODE_PRIVATE).edit().clear().commit() context.getSharedPreferences("native-session", Context.MODE_PRIVATE).edit().clear().commit()
if (android.os.Build.VERSION.SDK_INT >= 37) { if (android.os.Build.VERSION.SDK_INT >= 37) {
@@ -72,23 +77,59 @@ class NativeUpstreamTest {
org.json.JSONObject().put("name", "Wochenmarkt"), org.json.JSONObject().put("name", "Wochenmarkt"),
).getJSONObject("data").getLong("id") ).getJSONObject("data").getLong("id")
seed.request("/shopping/$seedList/items", "POST", org.json.JSONObject().put("name", "Äpfel").put("quantity", "4")) 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") seed.request("/auth/logout", "POST")
compose.onNodeWithTag("server-url").performTextInput(origin) compose.onNodeWithTag("server-url").performTextInput(origin)
compose.onNodeWithTag("connect").performClick() compose.onNodeWithTag("connect").performClick()
awaitTag("username") 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("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("password").performTextInput(password)
compose.onNodeWithTag("login").performScrollTo().performClick() compose.onNodeWithTag("login").performScrollTo().performClick()
awaitTag("native-dashboard") awaitTag("native-dashboard")
captureFixtureDashboard() 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.CREATED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.activityRule.scenario.recreate() compose.activityRule.scenario.recreate()
awaitTag("native-dashboard") awaitTag("native-dashboard")
compose.onNodeWithTag("nav-TASKS").performClick() compose.onNodeWithTag("nav-TASKS").performClick()
awaitText("Hinzufügen") awaitTag("add-entry")
compose.onNodeWithText("Hinzufügen").performClick() compose.onNodeWithTag("add-entry").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native task regression") compose.onNodeWithTag("entry-title").performTextInput("Native task regression")
compose.onNodeWithTag("entry-detail").performTextInput("Created with native controls") compose.onNodeWithTag("entry-detail").performTextInput("Created with native controls")
compose.onNodeWithTag("entry-save").performClick() compose.onNodeWithTag("entry-save").performClick()
@@ -96,8 +137,8 @@ class NativeUpstreamTest {
assertTrue(readApi(origin).request("/tasks").rows().any { it.text("title") == "Native task regression" }) assertTrue(readApi(origin).request("/tasks").rows().any { it.text("title") == "Native task regression" })
compose.onNodeWithTag("nav-NOTES").performClick() compose.onNodeWithTag("nav-NOTES").performClick()
awaitText("Hinzufügen") awaitTag("add-entry")
compose.onNodeWithText("Hinzufügen").performClick() compose.onNodeWithTag("add-entry").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native note regression") compose.onNodeWithTag("entry-title").performTextInput("Native note regression")
compose.onNodeWithTag("entry-detail").performTextInput("Not an embedded web page") compose.onNodeWithTag("entry-detail").performTextInput("Not an embedded web page")
compose.onNodeWithTag("entry-save").performClick() compose.onNodeWithTag("entry-save").performClick()
@@ -105,14 +146,14 @@ class NativeUpstreamTest {
assertTrue(readApi(origin).request("/notes").rows().any { it.text("content") == "Not an embedded web page" }) assertTrue(readApi(origin).request("/notes").rows().any { it.text("content") == "Not an embedded web page" })
compose.onNodeWithTag("nav-SHOPPING").performClick() compose.onNodeWithTag("nav-SHOPPING").performClick()
awaitText("Neue Liste") awaitTag("add-list")
compose.onNodeWithText("Neue Liste").performClick() compose.onNodeWithTag("add-list").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native shopping regression") compose.onNodeWithTag("entry-title").performTextInput("Native shopping regression")
compose.onNodeWithTag("entry-save").performClick() compose.onNodeWithTag("entry-save").performClick()
awaitText("Native shopping regression") awaitText("Native shopping regression")
compose.onNodeWithText("Native shopping regression").performClick() compose.onNodeWithText("Native shopping regression").performClick()
awaitText("Hinzufügen") awaitTag("add-entry")
compose.onNodeWithText("Hinzufügen").performClick() compose.onNodeWithTag("add-entry").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native apples") compose.onNodeWithTag("entry-title").performTextInput("Native apples")
compose.onNodeWithTag("entry-detail").performTextInput("3") compose.onNodeWithTag("entry-detail").performTextInput("3")
compose.onNodeWithTag("entry-save").performClick() compose.onNodeWithTag("entry-save").performClick()
@@ -150,15 +191,67 @@ class NativeUpstreamTest {
return api 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. // Only this temporary-fixture test clears the screenshot restriction.
// Ordinary builds always retain FLAG_SECURE, including native login. // 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 { try {
compose.waitForIdle() 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 // Capture the native window through PixelCopy, not a potentially stale
// secure compositor frame returned by UiAutomation immediately after clearing. // 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 { try {
val first = image.getPixel(0, 0) val first = image.getPixel(0, 0)
assertTrue( assertTrue(
@@ -168,9 +261,20 @@ class NativeUpstreamTest {
}, },
) )
// AGP collects this test output before uninstalling the fixture APK. // 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)) 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 { } finally {
image.recycle() image.recycle()
} }
@@ -9,7 +9,9 @@ import org.json.JSONObject
data class SessionCookie(val name: String, val value: String, val expires: Long) data class SessionCookie(val name: String, val value: String, val expires: Long)
/** An explicit same-origin jar; never installs a process-wide CookieHandler. */ /** Origin-bound HTTPS-only jar; never installs a process-wide CookieHandler.
* The native transport enforces HTTPS even when the server omits cookie Secure.
*/
class NativeCookies(private val server: ServerAddress, serialized: String? = null) { class NativeCookies(private val server: ServerAddress, serialized: String? = null) {
private val values = linkedMapOf<String, SessionCookie>() private val values = linkedMapOf<String, SessionCookie>()
@@ -35,7 +37,10 @@ class NativeCookies(private val server: ServerAddress, serialized: String? = nul
for (header in headers) { for (header in headers) {
val parsed = runCatching { HttpCookie.parse(header) }.getOrDefault(emptyList()) val parsed = runCatching { HttpCookie.parse(header) }.getOrDefault(emptyList())
for (cookie in parsed) { for (cookie in parsed) {
if (!allowed(cookie.name) || !cookie.secure || !safe(cookie.value)) continue // Upstream defaults SESSION_SECURE to false, even behind an HTTPS
// proxy. Never downgrade transport: all of this jar's cookies are
// bound to ServerAddress's HTTPS origin regardless of this flag.
if (!allowed(cookie.name) || !safe(cookie.value)) continue
if (cookie.domain != null && !cookie.domain.trimStart('.').equals(host, true)) continue if (cookie.domain != null && !cookie.domain.trimStart('.').equals(host, true)) continue
if (cookie.path != null && cookie.path != "/") continue if (cookie.path != null && cookie.path != "/") continue
if (cookie.maxAge == 0L) { if (cookie.maxAge == 0L) {
@@ -61,12 +66,27 @@ class NativeCookies(private val server: ServerAddress, serialized: String? = nul
fun clear() = values.clear() fun clear() = values.clear()
fun hasSession(now: Long = System.currentTimeMillis()): Boolean = values["yuvomi.sid"]?.let {
it.value.isNotEmpty() && it.expires > now
} == true
private fun allowed(name: String) = name == "yuvomi.sid" || name == "csrf-token" private fun allowed(name: String) = name == "yuvomi.sid" || name == "csrf-token"
private fun safe(value: String) = value.length <= 8192 && value.none { it <= ' ' || it == ';' || it == ',' || it == '\u007f' } private fun safe(value: String) = value.length <= 8192 && value.none { it <= ' ' || it == ';' || it == ',' || it == '\u007f' }
} }
class NativeApiError(val status: Int, val reason: String = "") : Exception() { class NativeApiError(val status: Int, val reason: String = "", val authenticationStep: String = "") : Exception() {
fun userMessage(): String = when (status) { fun authenticationMessage(): String? = when {
status == 401 && reason == "session_cookie_missing" ->
"Der Server hat die Anmeldung bestätigt, aber kein nutzbares Sitzungscookie geliefert. Bitte Server und Reverse Proxy prüfen."
status == 401 && reason == "session_not_accepted" ->
"Die Anmeldung wurde bestätigt, aber der Server akzeptiert die Sitzung nicht. Bitte Server und Reverse Proxy prüfen."
status == 401 && authenticationStep == "/auth/login" -> "Benutzername oder Passwort wurden vom Server nicht akzeptiert."
status == 401 && authenticationStep == "/auth/2fa/verify" ->
"Der Bestätigungscode ist ungültig oder die ausstehende Anmeldung ist abgelaufen. Bitte erneut anmelden."
else -> null
}
fun userMessage(): String = authenticationMessage() ?: when (status) {
401 -> "Anmeldung oder Sitzung ist nicht mehr gültig." 401 -> "Anmeldung oder Sitzung ist nicht mehr gültig."
403 -> "Diese Aktion ist nicht erlaubt. Bitte prüfe deine Berechtigungen." 403 -> "Diese Aktion ist nicht erlaubt. Bitte prüfe deine Berechtigungen."
409 -> "Der Eintrag wurde inzwischen geändert. Bitte neu laden." 409 -> "Der Eintrag wurde inzwischen geändert. Bitte neu laden."
@@ -85,6 +105,10 @@ class NativeApi(val server: ServerAddress, private val cookies: NativeCookies, p
cookies.clear() cookies.clear()
} }
fun requireSessionCookie() {
if (!cookies.hasSession()) throw NativeApiError(401, "session_cookie_missing")
}
fun request(path: String, method: String = "GET", body: JSONObject? = null, retry: Boolean = true): JSONObject { fun request(path: String, method: String = "GET", body: JSONObject? = null, retry: Boolean = true): JSONObject {
require(path.startsWith("/") && !path.startsWith("//") && !path.contains('#') && !path.contains('\\')) require(path.startsWith("/") && !path.startsWith("//") && !path.contains('#') && !path.contains('\\'))
val target = URI(server.origin + "/api/v1" + path) val target = URI(server.origin + "/api/v1" + path)
@@ -136,7 +160,10 @@ class NativeApi(val server: ServerAddress, private val cookies: NativeCookies, p
request("/auth/me", retry = false) request("/auth/me", retry = false)
return request(path, method, body, retry = false) return request(path, method, body, retry = false)
} }
if (status !in 200..299) throw NativeApiError(status, data.optString("reason")) if (status !in 200..299) {
val step = path.takeIf { it == "/auth/login" || it == "/auth/2fa/verify" }.orEmpty()
throw NativeApiError(status, data.optString("reason"), step)
}
return data return data
} finally { } finally {
connection.disconnect() connection.disconnect()
File diff suppressed because it is too large. Load diff
@@ -31,6 +31,8 @@ data class NativeState(
val resetAllowed: Boolean = false, val resetAllowed: Boolean = false,
val page: NativePage = NativePage.HOME, val page: NativePage = NativePage.HOME,
val dashboard: JSONObject = JSONObject(), val dashboard: JSONObject = JSONObject(),
val preferences: JSONObject = JSONObject(),
val shoppingLists: List<JSONObject> = emptyList(),
val rows: List<JSONObject> = emptyList(), val rows: List<JSONObject> = emptyList(),
val listId: Long? = null, val listId: Long? = null,
val listName: String = "", val listName: String = "",
@@ -66,7 +68,7 @@ class NativeController(application: Application) : AndroidViewModel(application)
val selectedGeneration = generation val selectedGeneration = generation
state = previous.copy(busy = true, error = null) state = previous.copy(busy = true, error = null)
worker.execute { worker.execute {
val result = runCatching { block(previous) } val result = runCatching { block(previous.copy(error = null)) }
main.post { main.post {
if (generation != selectedGeneration) return@post if (generation != selectedGeneration) return@post
state = result.fold( state = result.fold(
@@ -77,6 +79,8 @@ class NativeController(application: Application) : AndroidViewModel(application)
user = null, user = null,
permissions = JSONObject(), permissions = JSONObject(),
dashboard = JSONObject(), dashboard = JSONObject(),
preferences = JSONObject(),
shoppingLists = emptyList(),
rows = emptyList(), rows = emptyList(),
page = NativePage.HOME, page = NativePage.HOME,
listId = null, listId = null,
@@ -165,8 +169,15 @@ class NativeController(application: Application) : AndroidViewModel(application)
} }
private fun authenticated(previous: NativeState, response: JSONObject): NativeState { private fun authenticated(previous: NativeState, response: JSONObject): NativeState {
requireNotNull(client).requireSessionCookie()
val user = response.getJSONObject("user") val user = response.getJSONObject("user")
val permissions = response.getJSONObject("permissions") val permissions = response.getJSONObject("permissions")
val preferences = try {
requireNotNull(client).request("/preferences").optJSONObject("data") ?: JSONObject()
} catch (error: Exception) {
if (error is NativeApiError && error.status == 401) throw error
JSONObject()
}
val signedIn = previous.copy( val signedIn = previous.copy(
user = user, user = user,
permissions = permissions, permissions = permissions,
@@ -174,12 +185,14 @@ class NativeController(application: Application) : AndroidViewModel(application)
twoFactor = false, twoFactor = false,
page = NativePage.HOME, page = NativePage.HOME,
dashboard = JSONObject(), dashboard = JSONObject(),
preferences = preferences,
shoppingLists = emptyList(),
rows = emptyList(), rows = emptyList(),
) )
return try { return try {
signedIn.copy(dashboard = requireNotNull(client).request("/dashboard")) signedIn.copy(dashboard = requireNotNull(client).request("/dashboard"))
} catch (error: Exception) { } catch (error: Exception) {
if (error is NativeApiError && error.status == 401) throw error if (error is NativeApiError && error.status == 401) throw NativeApiError(401, "session_not_accepted")
signedIn.copy(error = "Die Anmeldung war erfolgreich. Die Übersicht konnte noch nicht geladen werden.") signedIn.copy(error = "Die Anmeldung war erfolgreich. Die Übersicht konnte noch nicht geladen werden.")
} }
} }
@@ -195,7 +208,16 @@ class NativeController(application: Application) : AndroidViewModel(application)
NativePage.HOME -> previous.copy(dashboard = api.request("/dashboard")) NativePage.HOME -> previous.copy(dashboard = api.request("/dashboard"))
NativePage.TASKS -> previous.copy(rows = api.request("/tasks").rows()) NativePage.TASKS -> previous.copy(rows = api.request("/tasks").rows())
NativePage.NOTES -> previous.copy(rows = api.request("/notes").rows()) NativePage.NOTES -> previous.copy(rows = api.request("/notes").rows())
NativePage.SHOPPING -> previous.copy(rows = api.request(previous.listId?.let { "/shopping/$it/items" } ?: "/shopping").rows()) NativePage.SHOPPING -> {
val lists = api.request("/shopping").rows()
val selected = lists.firstOrNull { it.optLong("id") == previous.listId } ?: lists.firstOrNull()
previous.copy(
shoppingLists = lists,
listId = selected?.getLong("id"),
listName = selected?.text("name").orEmpty(),
rows = selected?.let { api.request("/shopping/${it.getLong("id")}/items").rows() }.orEmpty(),
)
}
NativePage.MORE -> previous NativePage.MORE -> previous
} }
} }
@@ -269,6 +291,21 @@ class NativeController(application: Application) : AndroidViewModel(application)
load(previous) load(previous)
} }
fun createShoppingList(name: String) = perform { previous ->
val list = requireNotNull(client).request("/shopping", "POST", JSONObject().put("name", name.trim())).getJSONObject("data")
load(previous.copy(listId = list.getLong("id")))
}
fun saveDashboardVisibility(visible: Set<String>) = perform { previous ->
// A failed preference read must never overwrite unknown stored widgets.
check(previous.preferences.has("dashboard_widgets"))
val editable = pwaSupportedWidgets.filter { previous.moduleVisible(it) && previous.widget(it) }.toSet()
val widgets = pwaWidgetVisibility(previous.preferences, visible, editable)
requireNotNull(client).request("/preferences", "PUT", JSONObject().put("dashboard_widgets", widgets))
val preferences = requireNotNull(client).request("/preferences").getJSONObject("data")
load(previous.copy(preferences = preferences))
}
fun checkItem(item: JSONObject) = perform { previous -> fun checkItem(item: JSONObject) = perform { previous ->
requireNotNull(client).request( requireNotNull(client).request(
"/shopping/items/${item.getLong("id")}", "/shopping/items/${item.getLong("id")}",
@@ -308,6 +345,8 @@ class NativeController(application: Application) : AndroidViewModel(application)
user = null, user = null,
permissions = JSONObject(), permissions = JSONObject(),
dashboard = JSONObject(), dashboard = JSONObject(),
preferences = JSONObject(),
shoppingLists = emptyList(),
rows = emptyList(), rows = emptyList(),
listId = null, listId = null,
page = NativePage.HOME, page = NativePage.HOME,
@@ -0,0 +1,156 @@
package community.yuvomi.mobile
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import java.time.LocalDate
import org.json.JSONObject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NativeEntryEditor(
state: NativeState,
entry: JSONObject?,
creatingList: Boolean,
dismiss: () -> Unit,
save: (String, String, String, String) -> Unit,
) {
val colors = LocalPwaPalette.current
val keyboard = LocalSoftwareKeyboardController.current
var title by remember { mutableStateOf(entry?.text(if (state.page == NativePage.SHOPPING) "name" else "title").orEmpty()) }
var detail by remember {
mutableStateOf(
entry?.text(
when (state.page) {
NativePage.NOTES -> "content"
NativePage.SHOPPING -> "quantity"
else -> "description"
},
).orEmpty(),
)
}
var priority by remember { mutableStateOf(entry?.text("priority")?.ifBlank { "none" } ?: "none") }
var date by remember { mutableStateOf(entry?.text("due_date").orEmpty()) }
val validDate = date.isBlank() || runCatching { LocalDate.parse(date) }.isSuccess
val titleText = if (entry != null) {
"Bearbeiten"
} else {
when (state.page) {
NativePage.TASKS -> "Neue Aufgabe"
NativePage.NOTES -> "Neue Notiz"
NativePage.SHOPPING -> if (creatingList) "Neue Einkaufsliste" else "Neuer Artikel"
else -> "Hinzufügen"
}
}
ModalBottomSheet(
onDismissRequest = { if (!state.busy) dismiss() },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = colors.raised,
shape = RoundedCornerShape(topStart = 26.dp, topEnd = 26.dp),
dragHandle = null,
) {
Column(Modifier.fillMaxWidth().imePadding().heightIn(max = 720.dp)) {
Row(
Modifier.fillMaxWidth().padding(start = 20.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(titleText, Modifier.weight(1f), style = PwaType.section)
PwaIconButton(PwaGlyph.CLOSE, "Schließen", dismiss, enabled = !state.busy, outlined = false, tint = colors.secondary)
}
HorizontalDivider(color = colors.border)
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
state.error?.let { Text(it, style = PwaType.caption, color = colors.danger) }
PwaField(title, { title = it }, if (state.page == NativePage.SHOPPING) "Name" else "Titel", Modifier.testTag("entry-title"))
if (state.page != NativePage.SHOPPING || !creatingList) {
PwaField(
detail,
{ detail = it },
when (state.page) {
NativePage.NOTES -> "Inhalt"
NativePage.SHOPPING -> "Menge"
else -> "Beschreibung"
},
Modifier.testTag("entry-detail"),
singleLine = state.page == NativePage.SHOPPING,
)
}
if (state.page == NativePage.TASKS) {
Text("Priorität", style = PwaType.secondary, color = colors.secondary)
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("none", "low", "medium", "high", "urgent").forEach { value ->
Text(
pwaPriority(value).ifBlank { "Keine" },
Modifier.clip(CircleShape).background(
if (value ==
priority
) {
colors.accent.copy(alpha = .16f)
} else {
colors.well
},
)
.clickable { priority = value }.padding(horizontal = 12.dp, vertical = 10.dp),
style = PwaType.button,
color = if (value == priority) colors.accent else colors.secondary,
)
}
}
PwaField(date, { date = it }, "Fällig am (JJJJ-MM-TT)")
if (!validDate) Text("Bitte ein gültiges Datum eingeben.", style = PwaType.caption, color = colors.danger)
}
}
HorizontalDivider(color = colors.border)
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
PwaTextAction("Abbrechen", dismiss, enabled = !state.busy)
Spacer(Modifier.weight(1f))
PwaButton(
"Speichern",
{
keyboard?.hide()
save(title, detail, priority, date)
},
Modifier.testTag("entry-save"),
validDate && !state.busy && (title.isNotBlank() || state.page == NativePage.NOTES && detail.isNotBlank()) &&
(state.page != NativePage.NOTES || detail.isNotBlank()),
)
}
}
}
}
@@ -0,0 +1,679 @@
package community.yuvomi.mobile
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
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.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import java.time.format.DateTimeFormatter
import java.util.Locale
import org.json.JSONObject
@Composable
fun NativeDashboard(state: NativeState, navigate: (NativePage) -> Unit, customize: () -> Unit) {
val colors = LocalPwaPalette.current
var search by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
val now = state.householdNow()
BoxWithConstraints(Modifier.fillMaxSize().testTag("native-dashboard")) {
val columns = if (maxWidth >= 768.dp) 2 else 1
val side = if (maxWidth < 640.dp) 12.dp else 16.dp
LazyVerticalGrid(
GridCells.Fixed(columns),
contentPadding = PaddingValues(start = side, end = side, top = 16.dp, bottom = 112.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Column(Modifier.padding(top = 12.dp, bottom = 8.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
now.format(DateTimeFormatter.ofPattern("EEEE, d. MMMM", Locale.GERMAN)).uppercase(Locale.GERMAN),
Modifier.weight(1f),
style = PwaType.label,
color = colors.secondary,
)
PwaIconButton(PwaGlyph.SEARCH, "Übersicht durchsuchen", { search = !search })
PwaIconButton(PwaGlyph.FILTER, "Übersicht anpassen", customize, enabled = !state.busy)
}
Spacer(Modifier.height(4.dp))
Text(
pwaGreeting(
state.user?.text("display_name").orEmpty().ifBlank {
state.user?.text("username").orEmpty()
},
now.hour,
),
style = PwaType.largeTitle,
color = colors.text,
modifier = Modifier.testTag("dashboard-greeting"),
)
if (search) {
Box(Modifier.padding(top = 12.dp)) {
PwaField(query, {
query = it
}, "Übersicht durchsuchen", showLabel = false, placeholder = "In der Übersicht suchen")
}
}
}
}
items(state.dashboardTiles()) { id ->
val data = state.dashboard
when (id) {
"tasks" -> PwaWidget("Aufgaben", PwaGlyph.TASKS, colors.work, data.optInt("openTaskCount"), {
navigate(NativePage.TASKS)
}) {
val rows = data.rows("urgentTasks").filter { it.text("title").contains(query, true) }
if (rows.isEmpty()) {
WidgetEmpty(
PwaGlyph.TASKS,
if (query.isBlank()) "Keine dringenden Aufgaben" else "Keine Treffer",
)
}
rows.forEach { task ->
Row(
Modifier.padding(vertical = 8.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier.padding(top = 7.dp).size(10.dp).clip(CircleShape).background(
if (task.text("priority") ==
"urgent"
) {
colors.danger
} else {
colors.kitchen
},
),
)
Column {
Text(task.text("title"), style = PwaType.secondary, fontWeight = FontWeight.SemiBold)
val due = pwaDueLabel(task.text("due_date"), now.toLocalDate())
if (due.isNotBlank()) Text(due, style = PwaType.caption, color = colors.warning)
}
}
}
}
"calendar" -> PwaWidget("Kalender", PwaGlyph.CALENDAR, colors.time, null, null) {
val events = data.rows("upcomingEvents").filter { it.text("title").contains(query, true) }
if (events.isEmpty()) WidgetEmpty(PwaGlyph.CALENDAR, "Keine Termine")
events.forEach { event ->
Column(Modifier.padding(vertical = 8.dp)) {
Text(event.text("title"), style = PwaType.secondary, fontWeight = FontWeight.SemiBold)
Text(
event.text("start_datetime").replace('T', ' ').take(16),
style = PwaType.caption,
color = colors.secondary,
)
}
}
}
"shopping" -> PwaWidget("Einkauf", PwaGlyph.SHOPPING, colors.kitchen, data.optInt("shoppingOpenCount"), {
navigate(NativePage.SHOPPING)
}) {
val lists = data.rows("shoppingLists").filter { list ->
(
list.text("name") +
list.rows("items").joinToString { it.text("name") }
).contains(query, true)
}
if (lists.isEmpty()) WidgetEmpty(PwaGlyph.SHOPPING, "Alles eingekauft")
lists.forEach { list ->
Row(
Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(list.text("name"), Modifier.weight(1f), style = PwaType.secondary, fontWeight = FontWeight.SemiBold)
Text(
"${list.optInt("total_count") - list.optInt("open_count")}/${list.optInt("total_count")}",
style = PwaType.caption,
color = colors.secondary,
)
}
Box(Modifier.fillMaxWidth().height(4.dp).clip(CircleShape).background(colors.well))
list.rows("items").take(3).forEach { item ->
Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
Modifier.padding(
top = 3.dp,
).size(14.dp).border(1.dp, colors.controlBorder, RoundedCornerShape(4.dp)),
)
Text(item.text("name"), style = PwaType.secondary)
if (item.text(
"quantity",
).isNotBlank()
) {
Text(item.text("quantity"), style = PwaType.caption, color = colors.secondary)
}
}
}
}
}
"notes" -> PwaWidget("Notizen", PwaGlyph.NOTES, colors.records, data.optInt("pinnedNotesCount"), {
navigate(NativePage.NOTES)
}) {
val notes = data.rows("pinnedNotes").filter { (it.text("title") + it.text("content")).contains(query, true) }
if (notes.isEmpty()) WidgetEmpty(PwaGlyph.NOTES, "Keine angepinnten Notizen")
notes.forEach { note ->
Column(Modifier.padding(vertical = 4.dp)) {
if (note.text(
"title",
).isNotBlank()
) {
Text(note.text("title"), style = PwaType.secondary, fontWeight = FontWeight.SemiBold)
}
Text(
note.text("content"),
style = PwaType.caption,
color = colors.secondary,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
}
}
@Composable
private fun PwaWidget(title: String, glyph: PwaGlyph, tint: Color, count: Int?, open: (() -> Unit)?, content: @Composable () -> Unit) {
val colors = LocalPwaPalette.current
PwaCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(1.dp)) {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 8.dp).heightIn(min = 32.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PwaModuleSeal(glyph, tint)
Spacer(Modifier.width(8.dp))
Text(title, style = PwaType.headline, color = colors.text)
if (count != null && count > 0) {
Spacer(Modifier.width(8.dp))
PwaBadge(count.toString())
}
Spacer(Modifier.weight(1f))
if (open != null) PwaTextAction("Alle", open, color = colors.secondary, compact = true)
}
Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 16.dp)) { content() }
}
}
}
@Composable
private fun WidgetEmpty(icon: PwaGlyph, text: String) {
val colors = LocalPwaPalette.current
Row(
Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PwaIcon(icon, colors.tertiary.copy(alpha = .6f))
Text(text, style = PwaType.button, color = colors.tertiary)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NativeCollection(
state: NativeState,
onEdit: (JSONObject) -> Unit,
onTask: (JSONObject) -> Unit,
onPin: (JSONObject) -> Unit,
onItem: (JSONObject) -> Unit,
onList: (JSONObject) -> Unit,
onNewList: () -> Unit,
onRemove: (String) -> Unit,
onRefresh: () -> Unit,
) {
val colors = LocalPwaPalette.current
val write = !state.busy && state.access(state.page.name.lowercase()) == "write"
var search by remember(state.page) { mutableStateOf(false) }
var query by remember(state.page) { mutableStateOf("") }
var filters by remember(state.page) { mutableStateOf(false) }
var status by remember { mutableStateOf("open") }
var onlyPinned by remember { mutableStateOf(false) }
var tools by remember { mutableStateOf(false) }
val rows = state.rows.filter { row ->
val title = if (state.page == NativePage.SHOPPING) row.text("name") else row.text("title")
(query.isBlank() || (title + row.text("description") + row.text("content")).contains(query, true)) &&
(
state.page != NativePage.TASKS || status == "all" ||
if (status == "done") row.text("status") == "done" else row.text("status") != "done"
) &&
(state.page != NativePage.NOTES || !onlyPinned || row.optInt("pinned") == 1)
}
Column(Modifier.fillMaxSize()) {
if (state.page == NativePage.SHOPPING) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Other kitchen modules remain visibly unavailable, never browser fallback routes.
listOf("Mahlzeiten", "Rezepte", "Einkauf", "Vorrat").forEach { label ->
val active = label == "Einkauf"
Box(
Modifier.clip(
CircleShape,
).background(if (active) colors.surface else Color.Transparent).padding(horizontal = 8.dp, vertical = 12.dp),
) {
Text(
label,
style = PwaType.button,
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal,
color = if (active) colors.kitchen else colors.secondary,
)
}
}
}
HorizontalDivider(color = colors.border.copy(alpha = .5f))
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(Modifier.weight(1f).horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
state.shoppingLists.forEach { list ->
val active = state.listId == list.optLong("id")
PwaCard(color = if (active) colors.surface else colors.background, radius = 24.dp) {
Row(
Modifier.clickable(enabled = !state.busy) {
onList(list)
}.heightIn(min = 48.dp).padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
list.text("name"),
style = PwaType.button,
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium,
color = if (active) colors.kitchen else colors.secondary,
)
PwaBadge(list.optInt("item_total").toString())
}
}
}
PwaIconButton(
PwaGlyph.PLUS,
"Neue Liste",
onNewList,
Modifier.testTag("add-list"),
enabled = write,
tint = colors.secondary,
)
}
Box {
PwaIconButton(PwaGlyph.MORE, "Listenaktionen", { tools = true })
DropdownMenu(tools, { tools = false }, containerColor = colors.raised) {
DropdownMenuItem({ Text("Aktualisieren", style = PwaType.secondary) }, {
tools = false
onRefresh()
})
DropdownMenuItem({ Text("Liste löschen", style = PwaType.secondary, color = colors.danger) }, {
tools = false
state.listId?.let { onRemove("/shopping/$it") }
}, enabled = write && state.listId != null)
}
}
}
} else {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
PwaModuleSeal(state.page.pwaGlyph(), colors.module(state.page), 32.dp)
Text(state.page.title, Modifier.weight(1f), style = PwaType.largeTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
PwaIconButton(PwaGlyph.SEARCH, "Suchen", { search = !search })
PwaIconButton(PwaGlyph.FILTER, "Filter", { filters = true })
if (state.page == NativePage.TASKS) {
Box {
PwaIconButton(PwaGlyph.MORE, "Aufgabenaktionen", { tools = true })
DropdownMenu(tools, { tools = false }, containerColor = colors.raised) {
DropdownMenuItem({ Text("Aktualisieren", style = PwaType.secondary) }, {
tools = false
onRefresh()
})
DropdownMenuItem({ Text("Alle Aufgaben", style = PwaType.secondary) }, {
tools = false
status = "all"
})
}
}
}
}
}
HorizontalDivider(color = colors.border)
if (search) {
Box(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
PwaField(query, { query = it }, "Suchen", placeholder = "Suchen", showLabel = false)
}
}
LazyColumn(
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = if (state.page ==
NativePage.SHOPPING
) {
0.dp
} else {
12.dp
},
bottom = 112.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (rows.isEmpty() && !state.busy) {
item {
Column(
Modifier.fillMaxWidth().padding(vertical = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
PwaIcon(if (state.page == NativePage.SHOPPING) PwaGlyph.SHOPPING else state.page.pwaGlyph(), colors.tertiary, 32.dp)
Text(
if (query.isBlank()) "Noch keine Einträge" else "Keine Treffer",
style = PwaType.headline,
color = colors.secondary,
)
Text(
if (write) "Neue Einträge erstellst du mit +." else "Du hast hier Lesezugriff.",
style = PwaType.caption,
color = colors.tertiary,
)
}
}
}
if (state.page == NativePage.NOTES) {
items(
rows.sortedByDescending {
it.optInt("pinned")
},
key = {
it.getLong("id")
},
) { note -> NativeNoteCard(note, write, { onEdit(note) }, { onPin(note) }, { onRemove("/notes/${note.getLong("id")}") }) }
} else {
rows.groupBy { pwaGroup(it.text("category")) }.forEach { (group, groupRows) ->
item(key = "group-$group") {
Row(
Modifier.fillMaxWidth().heightIn(min = if (state.page == NativePage.SHOPPING) 16.dp else 20.dp)
.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
PwaIcon(PwaGlyph.CHEVRON_DOWN, colors.secondary, 16.dp)
Text(group.uppercase(Locale.GERMAN), style = PwaType.label, color = colors.secondary)
Text(groupRows.size.toString(), style = PwaType.caption, color = colors.tertiary)
}
}
item(key = "rows-$group") {
PwaCard(Modifier.fillMaxWidth()) {
Column {
groupRows.forEachIndexed { index, row ->
if (index >
0
) {
HorizontalDivider(
color = colors.border.copy(alpha = .6f),
modifier = Modifier.padding(start = 48.dp),
)
}
NativeListRow(state.page, row, write, { onEdit(row) }, {
if (state.page ==
NativePage.TASKS
) {
onTask(row)
} else {
onItem(row)
}
}, {
onRemove(
if (state.page ==
NativePage.TASKS
) {
"/tasks/${row.getLong("id")}"
} else {
"/shopping/items/${row.getLong("id")}"
},
)
})
}
}
}
}
}
}
}
}
if (filters) {
ModalBottomSheet(onDismissRequest = { filters = false }, containerColor = colors.raised) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Filter", style = PwaType.section)
if (state.page == NativePage.TASKS) {
listOf("open" to "Offen", "done" to "Erledigt", "all" to "Alle").forEach { (value, label) ->
Row(verticalAlignment = Alignment.CenterVertically) {
if (status == value) PwaIcon(PwaGlyph.CHECK, colors.accent, 16.dp)
PwaTextAction(label, {
status = value
filters = false
})
}
}
} else {
PwaTextAction(if (onlyPinned) "Alle Notizen" else "Nur angepinnte Notizen", {
onlyPinned = !onlyPinned
filters = false
})
}
Spacer(Modifier.height(16.dp))
}
}
}
}
@Composable
private fun NativeListRow(page: NativePage, row: JSONObject, write: Boolean, edit: () -> Unit, toggle: () -> Unit, remove: () -> Unit) {
val colors = LocalPwaPalette.current
var menu by remember { mutableStateOf(false) }
val checked = if (page == NativePage.TASKS) row.text("status") == "done" else row.optInt("is_checked") == 1
val shopping = page == NativePage.SHOPPING
Row(
Modifier.fillMaxWidth().padding(horizontal = if (shopping) 20.dp else 8.dp, vertical = 8.dp),
verticalAlignment = if (shopping) Alignment.CenterVertically else Alignment.Top,
) {
Box(
Modifier.size(32.dp).clip(CircleShape).toggleable(checked, enabled = write, role = Role.Checkbox) { toggle() }
.semantics { contentDescription = row.text(if (page == NativePage.SHOPPING) "name" else "title") },
contentAlignment = Alignment.Center,
) {
Box(
Modifier.size(
if (shopping) 24.dp else 20.dp,
).clip(
CircleShape,
).background(
if (checked) colors.work else Color.Transparent,
).border(2.dp, if (checked) colors.work else colors.tertiary, CircleShape),
contentAlignment = Alignment.Center,
) {
if (checked) PwaIcon(PwaGlyph.CHECK, colors.vividInk, 14.dp)
}
}
Spacer(Modifier.width(if (shopping) 20.dp else 8.dp))
Column(Modifier.weight(1f).clickable(enabled = write, onClick = edit).padding(top = 1.dp, bottom = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
if (page ==
NativePage.SHOPPING
) {
row.text("name")
} else {
row.text("title")
},
style = PwaType.rowTitle,
textDecoration = if (checked) TextDecoration.LineThrough else null,
)
if (shopping &&
row.text("quantity").isNotBlank()
) {
Text(row.text("quantity"), style = PwaType.secondary, color = colors.secondary)
}
}
if (page == NativePage.TASKS) {
val priority = pwaPriority(row.text("priority"))
val due = pwaDueLabel(row.text("due_date"))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
if (priority.isNotBlank()) {
Box(
Modifier.size(8.dp).clip(CircleShape).background(
if (row.text("priority") ==
"urgent"
) {
colors.danger
} else {
colors.kitchen
},
),
)
Text(priority, style = PwaType.caption, color = colors.secondary)
}
if (due.isNotBlank()) Text(due, style = PwaType.caption, color = colors.warning, fontWeight = FontWeight.SemiBold)
}
}
}
if (write) {
Box {
PwaIconButton(PwaGlyph.MORE, "Weitere Aktionen", { menu = true }, outlined = false, tint = colors.tertiary, size = 40.dp)
DropdownMenu(menu, { menu = false }, containerColor = colors.raised) {
DropdownMenuItem({ Text("Bearbeiten", style = PwaType.secondary) }, {
menu = false
edit()
})
DropdownMenuItem({ Text("Löschen", style = PwaType.secondary, color = colors.danger) }, {
menu = false
remove()
})
}
}
}
// Upstream reserves the drag-handle column even for a single task.
if (!shopping) Spacer(Modifier.width(34.dp))
}
}
@Composable
private fun NativeNoteCard(note: JSONObject, write: Boolean, edit: () -> Unit, pin: () -> Unit, remove: () -> Unit) {
val colors = LocalPwaPalette.current
var menu by remember { mutableStateOf(false) }
PwaCard(Modifier.fillMaxWidth(), color = pwaNoteColor(note, colors)) {
Box {
Column(Modifier.padding(12.dp)) {
Text(
note.text("title"),
Modifier.fillMaxWidth().padding(end = 32.dp).clickable(enabled = write, onClick = edit),
style = PwaType.headline,
)
Spacer(Modifier.height(4.dp))
Text(
note.text("content"),
Modifier.fillMaxWidth().clickable(enabled = write, onClick = edit),
style = PwaType.body.copy(lineHeight = 27.2.sp),
maxLines = 12,
overflow = TextOverflow.Ellipsis,
)
HorizontalDivider(color = colors.border.copy(alpha = .6f), modifier = Modifier.padding(top = 8.dp, bottom = 4.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
val avatar =
note.text("creator_color").takeIf {
it.matches(Regex("#[0-9a-fA-F]{6}"))
}?.let { Color(it.toColorInt()) }
?: colors.records
Box(Modifier.size(16.dp).clip(CircleShape).background(avatar))
Spacer(Modifier.width(4.dp))
Text(note.text("creator_name"), Modifier.weight(1f), style = PwaType.caption, color = colors.secondary)
if (write) {
Box {
PwaIconButton(PwaGlyph.MORE, "Notizaktionen", {
menu = true
}, outlined = false, tint = colors.tertiary, size = 24.dp)
DropdownMenu(menu, { menu = false }, containerColor = colors.raised) {
DropdownMenuItem({ Text("Bearbeiten", style = PwaType.secondary) }, {
menu = false
edit()
})
DropdownMenuItem({ Text("Löschen", style = PwaType.secondary, color = colors.danger) }, {
menu = false
remove()
})
}
}
}
}
}
PwaIconButton(
if (note.optInt("pinned") == 1) PwaGlyph.PIN_FILLED else PwaGlyph.PIN,
if (note.optInt("pinned") == 1) "Lösen" else "Anheften",
pin,
Modifier.align(Alignment.TopEnd).padding(top = 8.dp, end = 8.dp),
enabled = write,
outlined = false,
tint = if (note.optInt("pinned") == 1) colors.text else colors.tertiary,
size = 32.dp,
)
}
}
}
@@ -0,0 +1,378 @@
package community.yuvomi.mobile
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
/** Literal roles from pinned PWA tokens.css / typography.css, not Material defaults. */
object PwaType {
private fun role(size: Float, weight: FontWeight = FontWeight.Normal, height: Float = 1.47f, tracking: Float = 0f) = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = size.sp,
fontWeight = weight,
lineHeight = (size * height).sp,
letterSpacing = tracking.sp,
platformStyle = PlatformTextStyle(includeFontPadding = false),
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None),
)
val largeTitle = role(34f, FontWeight.Bold, 1.21f, -.51f)
val toolbar = role(22f, FontWeight.Bold, 1.21f)
val section = role(20f, FontWeight.SemiBold, 1.3f)
val headline = role(17f, FontWeight.SemiBold, 1.3f)
val body = role(17f)
val rowTitle = role(16f, FontWeight.Medium, 1.3f)
val secondary = role(15f)
val caption = role(13f)
val micro = role(11f, FontWeight.Medium, 1.2f)
val label = role(12f, FontWeight.SemiBold, 1.47f, .6f)
val field = role(16f, height = 1.3f)
val button = role(14f, FontWeight.Medium)
val badge = role(12f, FontWeight.Bold, 1f)
}
@Immutable
data class PwaPalette(
val dark: Boolean,
val background: Color,
val surface: Color,
val raised: Color,
val well: Color,
val text: Color,
val secondary: Color,
val tertiary: Color,
val border: Color,
val controlBorder: Color,
val field: Color,
val accent: Color,
val button: Color,
val vividInk: Color,
val work: Color,
val time: Color,
val kitchen: Color,
val records: Color,
val danger: Color,
val warning: Color,
) {
fun module(page: NativePage) = when (page) {
NativePage.HOME -> accent
NativePage.TASKS -> work
NativePage.SHOPPING -> kitchen
NativePage.NOTES -> records
NativePage.MORE -> tertiary
}
companion object {
fun forTheme(dark: Boolean) = if (dark) {
PwaPalette(
true, Color(0xFF191816), Color(0xFF2B2825), Color(0xFF37332E), Color(0xFF37332E),
Color(0xFFF5F3ED), Color(0xFFB4AEA5), Color(0xFFA9A39A), Color(0xFF47423B), Color(0xFF857F76),
Color(0xFF37332E), Color(0xFFA78BFA), Color(0xFFBBA6F9), Color(0xFF0A0A0C),
Color(0xFF4ADE80), Color(0xFF4CB8F0), Color(0xFFFB923C), Color(0xFF7E9BCC),
Color(0xFFFF6961), Color(0xFFFF9F0A),
)
} else {
PwaPalette(
false, Color(0xFFF5F3ED), Color.White, Color(0xFFFBFAF7), Color(0xFFEDEAE3),
Color(0xFF1D1B17), Color(0xFF63615B), Color(0xFF6B675F), Color(0xFFE4E0D7), Color(0xFF8C8880),
Color.White, Color(0xFF6C3AED), Color(0xFF6135D2), Color.White,
Color(0xFF157F3D), Color(0xFF00668F), Color(0xFFC2410C), Color(0xFF42587E),
Color(0xFFD70015), Color(0xFFA85D00),
)
}
}
}
val LocalPwaPalette = staticCompositionLocalOf { PwaPalette.forTheme(false) }
@Composable
fun PwaTheme(content: @Composable () -> Unit) {
val palette = PwaPalette.forTheme(isSystemInDarkTheme())
val scheme = if (palette.dark) darkColorScheme() else lightColorScheme()
CompositionLocalProvider(LocalPwaPalette provides palette, LocalContentColor provides palette.text) {
MaterialTheme(
colorScheme = scheme.copy(
primary = palette.accent, onPrimary = palette.vividInk,
background = palette.background, onBackground = palette.text,
surface = palette.surface, onSurface = palette.text, onSurfaceVariant = palette.secondary,
surfaceVariant = palette.well, outline = palette.controlBorder, outlineVariant = palette.border,
error = palette.danger,
),
typography = Typography(
bodyLarge = PwaType.body,
bodyMedium = PwaType.secondary,
bodySmall = PwaType.caption,
titleLarge = PwaType.toolbar,
titleMedium = PwaType.headline,
headlineLarge = PwaType.largeTitle,
labelLarge = PwaType.button,
labelSmall = PwaType.micro,
),
content = content,
)
}
}
@Composable
fun PwaCard(
modifier: Modifier = Modifier,
color: Color = LocalPwaPalette.current.surface,
radius: Dp = 12.dp,
content: @Composable () -> Unit,
) {
val shape = RoundedCornerShape(radius)
Surface(
modifier.shadow(6.dp, shape, ambientColor = Color.Black.copy(alpha = .28f), spotColor = Color.Black.copy(alpha = .28f)),
shape = shape,
color = color,
tonalElevation = 0.dp,
) { content() }
}
@Composable
fun PwaButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
val colors = LocalPwaPalette.current
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 48.dp),
shape = CircleShape,
colors = ButtonDefaults.buttonColors(containerColor = colors.button, contentColor = colors.vividInk),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) { Text(text, style = PwaType.button) }
}
@Composable
fun PwaIconButton(
icon: PwaGlyph,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
outlined: Boolean = true,
tint: Color = LocalPwaPalette.current.accent,
size: Dp = 48.dp,
) {
val colors = LocalPwaPalette.current
Box(
modifier.size(size).clip(CircleShape)
.then(if (outlined) Modifier.border(1.dp, colors.border, CircleShape) else Modifier)
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
.semantics { contentDescription = label },
contentAlignment = Alignment.Center,
) { PwaIcon(icon, if (enabled) tint else colors.tertiary, 20.dp) }
}
@Composable
fun PwaField(
value: String,
change: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
password: Boolean = false,
keyboard: KeyboardType = KeyboardType.Text,
singleLine: Boolean = true,
placeholder: String = "",
showLabel: Boolean = true,
) {
val colors = LocalPwaPalette.current
var revealed by remember { mutableStateOf(false) }
val interaction = remember { MutableInteractionSource() }
val focused by interaction.collectIsFocusedAsState()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (showLabel) Text(label, style = PwaType.button, color = colors.secondary)
BasicTextField(
value, change, modifier.fillMaxWidth().semantics { contentDescription = label },
textStyle = PwaType.field.copy(color = colors.text),
singleLine = singleLine,
keyboardOptions = KeyboardOptions(keyboardType = if (password) KeyboardType.Password else keyboard),
visualTransformation = if (password && !revealed) PasswordVisualTransformation() else VisualTransformation.None,
cursorBrush = SolidColor(colors.accent), interactionSource = interaction,
decorationBox = { input ->
Row(
Modifier.fillMaxWidth().heightIn(min = if (singleLine) 48.dp else 104.dp)
.clip(RoundedCornerShape(10.dp)).background(colors.field)
.border(
if (focused) 2.dp else 1.dp,
if (focused) colors.accent else colors.controlBorder,
RoundedCornerShape(10.dp),
)
.padding(start = 12.dp, end = if (password) 0.dp else 12.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.weight(1f)) {
if (value.isEmpty()) Text(placeholder, style = PwaType.field, color = colors.tertiary)
input()
}
if (password) {
PwaIconButton(
if (revealed) PwaGlyph.EYE_OFF else PwaGlyph.EYE,
if (revealed) "Passwort verbergen" else "Passwort anzeigen",
{ revealed = !revealed },
outlined = false,
tint = colors.secondary,
size = 32.dp,
)
}
}
},
)
}
}
@Composable
fun PwaModuleSeal(icon: PwaGlyph, color: Color, size: Dp = 24.dp, iconSize: Dp = 20.dp) {
val colors = LocalPwaPalette.current
Box(
Modifier.size(size).clip(CircleShape).background(color)
.drawWithContent {
drawRect(
Brush.verticalGradient(
listOf(Color.White.copy(alpha = if (colors.dark) .16f else .35f), Color.Transparent),
endY = this.size.height * .55f,
),
)
drawContent()
},
contentAlignment = Alignment.Center,
) { PwaIcon(icon, colors.vividInk, iconSize) }
}
@Composable
fun PwaBadge(text: String) {
val colors = LocalPwaPalette.current
Box(Modifier.defaultMinSize(minWidth = 16.dp).clip(CircleShape).background(colors.well).padding(horizontal = 4.dp, vertical = 2.dp)) {
Text(text, style = PwaType.badge, color = colors.secondary)
}
}
@Composable
fun PwaTextAction(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
color: Color = LocalPwaPalette.current.accent,
compact: Boolean = false,
) {
Box(
modifier.clip(CircleShape).clickable(enabled = enabled, role = Role.Button, onClick = onClick)
.padding(horizontal = 8.dp, vertical = if (compact) 5.5.dp else 10.dp),
) {
Text(text, style = PwaType.button, color = color)
}
}
@Composable
fun PwaBrandMark(modifier: Modifier = Modifier) {
Canvas(modifier.size(64.dp).semantics { contentDescription = "Yuvomi" }) {
drawRoundRect(
Brush.linearGradient(listOf(Color(0xFF8B5CF6), Color(0xFF6C3AED))),
cornerRadius = CornerRadius(size.width * .225f),
)
listOf(Triple(64f, 72f, 27f), Triple(100f, 78f, 25f), Triple(80f, 106f, 24f)).forEach { (x, y, radius) ->
drawCircle(Color.White.copy(alpha = .82f), radius * size.width / 160, Offset(x * size.width / 160, y * size.height / 160))
}
}
}
@Composable
fun PwaBrandField(modifier: Modifier = Modifier) {
val colors = LocalPwaPalette.current
Canvas(modifier.background(colors.background)) {
val opacity = if (colors.dark) .26f else .20f
val stops = listOf(1f, .98f, .9f, .79f, .65f, .5f, .35f, .21f, .1f, .02f, 0f)
listOf(
Triple(Offset(size.width / 2 - 208.dp.toPx(), size.height / 2 - 192.dp.toPx()), 384.dp.toPx(), Color(0xFF8B5CF6)),
Triple(Offset(size.width / 2 + 240.dp.toPx(), size.height / 2 - 16.dp.toPx()), 352.dp.toPx(), Color(0xFF6C3AED)),
Triple(Offset(size.width / 2 - 16.dp.toPx(), size.height / 2 + 272.dp.toPx()), 368.dp.toPx(), Color(0xFF8B5CF6)),
).asReversed().forEachIndexed { index, (center, radius, color) ->
drawRect(
Brush.radialGradient(
*stops.mapIndexed { i, stop ->
i / 10f to color.copy(
alpha = stop * opacity * if (index ==
0
) {
.8f
} else {
1f
},
)
}.toTypedArray(),
center = center,
radius = radius,
),
)
}
}
}
fun pwaNoteColor(note: org.json.JSONObject, palette: PwaPalette): Color {
val raw = note.text("color").takeIf { it.matches(Regex("#[0-9a-fA-F]{6}")) } ?: "#FFEB3B"
val color = Color(raw.toColorInt())
return lerp(palette.surface, color, .16f)
}
@@ -0,0 +1,151 @@
package community.yuvomi.mobile
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/** Module geometry is adapted verbatim from upstream public/nav-icons.js (MIT).
* Utilities use the PWA's Lucide-style 24-unit geometry, never emoji or Material icons.
*/
enum class PwaGlyph {
OVERVIEW,
TASKS,
NOTES,
SHOPPING,
KITCHEN,
CALENDAR,
MORE,
SEARCH,
FILTER,
PLUS,
CHEVRON,
CHEVRON_DOWN,
CHECK,
TRASH,
PIN,
PIN_FILLED,
REFRESH,
CLOSE,
EYE,
EYE_OFF,
BACK,
SERVER,
}
private data class IconRect(val x: Float, val y: Float, val width: Float, val height: Float, val radius: Float)
private data class IconCircle(val x: Float, val y: Float, val radius: Float, val solid: Boolean = false)
private data class IconShape(
val paths: List<String> = emptyList(),
val rects: List<IconRect> = emptyList(),
val circles: List<IconCircle> = emptyList(),
)
private fun shape(glyph: PwaGlyph): IconShape = when (glyph) {
PwaGlyph.OVERVIEW -> IconShape(
rects = listOf(
IconRect(3.5f, 3.5f, 7.5f, 7.5f, 2.2f),
IconRect(13f, 3.5f, 7.5f, 5f, 2f),
IconRect(13f, 10.5f, 7.5f, 10f, 2.2f),
IconRect(3.5f, 12.5f, 7.5f, 8f, 2f),
),
)
PwaGlyph.TASKS -> IconShape(listOf("m8 12.3 2.8 2.8 5.6-5.6"), listOf(IconRect(3.5f, 3.5f, 17f, 17f, 4.5f)))
PwaGlyph.NOTES -> IconShape(
listOf(
"M5.5 4.5h9.5L18.5 8v11a1.5 1.5 0 0 1-1.5 1.5H5.5A1.5 1.5 0 0 1 4 19V6a1.5 1.5 0 0 1 1.5-1.5z",
"M15 4.5V7a1.5 1.5 0 0 0 1.5 1.5H19",
"M8 13h7M8 16.5h5",
),
)
PwaGlyph.SHOPPING -> IconShape(
listOf("M3 4.5h2.4L7.5 16h10.2l2-7H7"),
circles = listOf(IconCircle(9f, 19.2f, 1.4f), IconCircle(17f, 19.2f, 1.4f)),
)
PwaGlyph.KITCHEN -> IconShape(
listOf("M7.5 3.5v8a2 2 0 0 1-2 2h-.5v7", "M5.5 3.5v6M7.5 3.5v6M9.5 3.5v6", "M17 3.5c-1.8 0-3 1.5-3 3.5v5h2.2v8.5h1.6V3.5z"),
)
PwaGlyph.CALENDAR -> IconShape(
listOf("M3.5 10h17", "M8 3.2v4.6M16 3.2v4.6"),
listOf(IconRect(3.5f, 5.5f, 17f, 15f, 3f)),
listOf(IconCircle(8.5f, 14.5f, .9f, true), IconCircle(12f, 14.5f, .9f, true), IconCircle(15.5f, 14.5f, .9f, true)),
)
PwaGlyph.MORE -> IconShape(circles = listOf(IconCircle(5.5f, 12f, 1.7f), IconCircle(12f, 12f, 1.7f), IconCircle(18.5f, 12f, 1.7f)))
PwaGlyph.SEARCH -> IconShape(listOf("m21 21-4.34-4.34"), circles = listOf(IconCircle(11f, 11f, 8f)))
PwaGlyph.FILTER -> IconShape(listOf("M4 7h9M17 7h3M4 17h3M11 17h9"), circles = listOf(IconCircle(15f, 7f, 2f), IconCircle(9f, 17f, 2f)))
PwaGlyph.PLUS -> IconShape(listOf("M12 5v14M5 12h14"))
PwaGlyph.CHEVRON -> IconShape(listOf("m9 5 7 7-7 7"))
PwaGlyph.CHEVRON_DOWN -> IconShape(listOf("m6 9 6 6 6-6"))
PwaGlyph.CHECK -> IconShape(listOf("m5 12 4 4L19 6"))
PwaGlyph.TRASH -> IconShape(listOf("M3 6h18M9 6V4h6v2M5 6l1 14h12l1-14M10 10v6M14 10v6"))
PwaGlyph.PIN -> IconShape(listOf("M16 9V3H8v6l-3 3v2h14v-2l-3-3M12 14v7"))
PwaGlyph.PIN_FILLED -> IconShape(listOf("M16 9V3H8v6l-3 3v2h14v-2l-3-3Z", "M12 14v7"))
PwaGlyph.REFRESH -> IconShape(listOf("M20 7v5h-5M4 17v-5h5M6.1 7a7 7 0 0 1 11.55-2.6L20 7M4 17l2.35 2.6A7 7 0 0 0 17.9 17"))
PwaGlyph.CLOSE -> IconShape(listOf("M6 6l12 12M18 6 6 18"))
PwaGlyph.EYE -> IconShape(listOf("M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z"), circles = listOf(IconCircle(12f, 12f, 3f)))
PwaGlyph.EYE_OFF -> IconShape(
listOf(
"M3 3l18 18M10.6 10.6a2 2 0 0 0 2.8 2.8M9.9 5.2A11 11 0 0 1 12 5c6.4 0 10 7 10 7a16 16 0 0 1-3.1 3.9" +
"M6.2 6.2A20 20 0 0 0 2 12s3.6 7 10 7a10 10 0 0 0 5.8-1.8",
),
)
PwaGlyph.BACK -> IconShape(listOf("m12 19-7-7 7-7M5 12h14"))
PwaGlyph.SERVER -> IconShape(listOf("M6 7h.01M6 17h.01"), listOf(IconRect(3f, 3f, 18f, 8f, 2f), IconRect(3f, 13f, 18f, 8f, 2f)))
}
@Composable
fun PwaIcon(glyph: PwaGlyph, color: Color, size: Dp = 20.dp, selected: Boolean = false) {
val icon = remember(glyph) { shape(glyph) }
val paths = remember(icon) { icon.paths.map { PathParser().parsePathString(it).toPath() } }
Canvas(Modifier.size(size)) {
val ratio = this.size.width / 24f
val module = glyph in setOf(
PwaGlyph.OVERVIEW,
PwaGlyph.TASKS,
PwaGlyph.NOTES,
PwaGlyph.SHOPPING,
PwaGlyph.KITCHEN,
PwaGlyph.CALENDAR,
PwaGlyph.MORE,
)
val stroke = Stroke(if (module) 1.6f else 2f, cap = StrokeCap.Round, join = StrokeJoin.Round)
scale(ratio, pivot = Offset.Zero) {
icon.rects.forEach {
if (selected) drawRoundRect(color.copy(alpha = .3f), Offset(it.x, it.y), Size(it.width, it.height), CornerRadius(it.radius))
drawRoundRect(color, Offset(it.x, it.y), Size(it.width, it.height), CornerRadius(it.radius), style = stroke)
}
paths.forEachIndexed { index, it ->
if (glyph == PwaGlyph.PIN_FILLED && index == 0) drawPath(it, color)
if (selected) drawPath(it, color.copy(alpha = .3f))
drawPath(it, color, style = stroke)
}
icon.circles.forEach {
if (it.solid) {
drawCircle(color, it.radius, Offset(it.x, it.y))
} else {
drawCircle(color, it.radius, Offset(it.x, it.y), style = stroke)
}
}
}
}
}
fun NativePage.pwaGlyph() = when (this) {
NativePage.HOME -> PwaGlyph.OVERVIEW
NativePage.TASKS -> PwaGlyph.TASKS
NativePage.NOTES -> PwaGlyph.NOTES
NativePage.SHOPPING -> PwaGlyph.KITCHEN
NativePage.MORE -> PwaGlyph.MORE
}
@@ -0,0 +1,107 @@
package community.yuvomi.mobile
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import org.json.JSONArray
import org.json.JSONObject
val pwaSupportedWidgets = listOf("tasks", "calendar", "shopping", "notes")
/** Presentation only. Household business rules and permissions remain server-owned. */
fun pwaGreeting(displayName: String, hour: Int): String {
val first = displayName.trim().split(Regex("\\s+")).firstOrNull().orEmpty()
val greeting = when (hour) {
in 5..11 -> "Guten Morgen"
in 12..17 -> "Guten Tag"
else -> "Guten Abend"
}
return if (first.isBlank()) greeting else "$greeting, $first"
}
fun pwaDueLabel(value: String, today: LocalDate = LocalDate.now()): String {
if (value.isBlank()) return ""
val date = runCatching { LocalDate.parse(value.take(10)) }.getOrNull() ?: return value
return when (date) {
today -> "Heute fällig"
today.plusDays(1) -> "Morgen fällig"
today.minusDays(1) -> "Gestern fällig"
else -> date.format(DateTimeFormatter.ofPattern("d. MMM", Locale.GERMAN))
}
}
fun pwaPriority(value: String) = when (value) {
"low" -> "Niedrig"
"medium" -> "Mittel"
"high" -> "Hoch"
"urgent" -> "Dringend"
else -> ""
}
fun pwaGroup(value: String) = value.takeUnless { it.isBlank() || it in setOf("general", "other", "misc") } ?: "Sonstiges"
fun NativeState.householdNow(instant: java.time.Instant = java.time.Instant.now()): ZonedDateTime {
val zone = runCatching { ZoneId.of(preferences.text("timezone_effective").ifBlank { preferences.text("timezone") }) }
.getOrDefault(ZoneId.systemDefault())
return instant.atZone(zone)
}
fun NativeState.moduleVisible(module: String): Boolean = access(module) != "none" &&
listOf("disabled_modules", "hidden_modules").none { key ->
val values = preferences.optJSONArray(key) ?: JSONArray()
(0 until values.length()).any { values.optString(it) == module }
}
fun NativeState.dashboardTiles(): List<String> {
val config = preferences.rows("dashboard_widgets")
return (
if (preferences.isNull("dashboard_widgets")) {
pwaSupportedWidgets
} else {
config.filter { it.optBoolean("visible", true) }.map { it.text("id") }
}
)
.filter { it in pwaSupportedWidgets && moduleVisible(it) && widget(it) }.distinct()
}
fun pwaWidgetVisibility(preferences: JSONObject, visible: Set<String>, editable: Set<String> = pwaSupportedWidgets.toSet()): JSONArray {
val existing = preferences.rows("dashboard_widgets")
val rows = if (preferences.isNull("dashboard_widgets")) {
pwaSupportedWidgets.mapIndexed { index, id ->
JSONObject().put("id", id).put("visible", true).put("order", index).put("size", "1x1")
}
} else {
existing
}
val result = JSONArray(
rows.map { row ->
JSONObject(row.toString()).apply {
if (text("id") in editable && text("id") in pwaSupportedWidgets) put("visible", text("id") in visible)
}
},
)
val configured = rows.map { it.text("id") }.toSet()
pwaSupportedWidgets.filter { it in visible && it in editable && it !in configured }.forEach { id ->
result.put(JSONObject().put("id", id).put("visible", true).put("order", result.length()).put("size", "1x1"))
}
return result
}
fun NativeState.nativeTabs(): List<NativePage> {
val order = preferences.optJSONArray("mobile_nav_order")?.let { array ->
(0 until array.length()).map { array.optString(it) }
}.orEmpty()
val pages = (order + listOf("tasks", "kitchen", "notes")).mapNotNull {
when (it) {
"tasks" -> NativePage.TASKS
"shopping", "kitchen" -> NativePage.SHOPPING
"notes" -> NativePage.NOTES
else -> null // Unimplemented modules are never fake navigation targets.
}
}.distinct().filter { moduleVisible(it.name.lowercase()) }
return listOf(NativePage.HOME) + pages + NativePage.MORE
}
fun NativePage.navigationLabel() = if (this == NativePage.SHOPPING) "Küche" else title
@@ -0,0 +1,254 @@
package community.yuvomi.mobile
import android.os.Build
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun PwaBottomNavigation(
state: NativeState,
backdrop: GraphicsLayer,
contentOrigin: Offset,
onNavigate: (NativePage) -> Unit,
onCreate: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = LocalPwaPalette.current
var origin by remember { mutableStateOf(Offset.Zero) }
val canCreate = !state.busy && when (state.page) {
NativePage.HOME -> listOf("tasks", "notes", "shopping").any { state.access(it) == "write" }
else -> state.access(state.page.name.lowercase()) == "write"
}
Box(modifier.padding(horizontal = 8.dp, vertical = 8.dp).widthIn(max = 560.dp).fillMaxWidth()) {
Box(
Modifier.fillMaxWidth().shadow(
14.dp,
CircleShape,
ambientColor = Color.Black.copy(alpha = .15f),
spotColor = Color.Black.copy(alpha = .18f),
)
.clip(CircleShape).onGloballyPositioned { origin = it.positionInRoot() }
.border(1.dp, Color.White.copy(alpha = if (colors.dark) .12f else .65f), CircleShape),
) {
if (Build.VERSION.SDK_INT >= 31) {
Canvas(
Modifier.matchParentSize().graphicsLayer {
val blur = 24.dp.toPx()
renderEffect = BlurEffect(blur, blur, TileMode.Clamp)
},
) {
translate(contentOrigin.x - origin.x, contentOrigin.y - origin.y) { drawLayer(backdrop) }
}
}
val glass = if (colors.dark) Color(0xFF2D2A27) else Color(0xFFFBFAF7)
Box(
Modifier.matchParentSize().background(
glass.copy(
alpha = if (Build.VERSION.SDK_INT <
31
) {
1f
} else if (colors.dark) {
.50f
} else {
.66f
},
),
),
)
Box(
Modifier.matchParentSize().background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = if (colors.dark) .06f else .24f), Color.Transparent)),
),
)
Row(
Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(start = 8.dp, end = 8.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
state.nativeTabs().forEach { page ->
val active = state.page == page
Column(
Modifier.weight(1f).clickable(enabled = !state.busy, role = Role.Tab) { onNavigate(page) }
.semantics {
selected = active
contentDescription = page.navigationLabel()
}.testTag("nav-${page.name}"),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
Modifier.size(width = 48.dp, height = 40.dp).clip(CircleShape)
.background(if (active) colors.accent.copy(alpha = if (colors.dark) .24f else .16f) else Color.Transparent),
contentAlignment = Alignment.Center,
) { PwaIcon(page.pwaGlyph(), if (active) colors.accent else colors.module(page), selected = active) }
Spacer(Modifier.height(2.dp))
Text(
page.navigationLabel(),
style = PwaType.micro,
color = if (active) colors.accent else colors.text,
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium,
textAlign = TextAlign.Center,
maxLines = 1,
)
}
}
Spacer(Modifier.width(4.dp))
Box(
Modifier.size(48.dp).clip(CircleShape)
.background(Brush.verticalGradient(listOf(colors.accent.copy(alpha = .76f), colors.accent)))
.border(1.dp, Color.White.copy(alpha = .24f), CircleShape)
.clickable(enabled = canCreate, role = Role.Button, onClick = onCreate)
.semantics { contentDescription = "Hinzufügen" }.testTag("add-entry"),
contentAlignment = Alignment.Center,
) { PwaIcon(PwaGlyph.PLUS, if (canCreate) colors.vividInk else colors.tertiary, 24.dp) }
}
}
}
}
@Composable
fun PwaMenuRow(
icon: PwaGlyph,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
tint: Color = LocalPwaPalette.current.accent,
) {
val colors = LocalPwaPalette.current
Row(
modifier.fillMaxWidth().heightIn(
min = 52.dp,
).clickable(role = Role.Button, onClick = onClick).padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
PwaModuleSeal(icon, tint, 32.dp)
Text(label, Modifier.weight(1f), style = PwaType.body, color = colors.text)
PwaIcon(PwaGlyph.CHEVRON, colors.tertiary, 16.dp)
}
}
@Composable
fun NativeMore(state: NativeState, navigate: (NativePage) -> Unit, logout: () -> Unit, forget: () -> Unit) {
val colors = LocalPwaPalette.current
Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp).padding(bottom = 24.dp)) {
Text("Mehr", style = PwaType.section, color = colors.text, modifier = Modifier.padding(bottom = 16.dp))
PwaCard(Modifier.fillMaxWidth()) {
Column {
state.nativeTabs().filter { it != NativePage.HOME && it != NativePage.MORE }.forEachIndexed { index, page ->
if (index > 0) HorizontalDivider(color = colors.border, modifier = Modifier.padding(start = 56.dp))
PwaMenuRow(page.pwaGlyph(), page.title, { navigate(page) }, tint = colors.module(page))
}
}
}
Spacer(Modifier.height(20.dp))
Text("KONTO", style = PwaType.label, color = colors.secondary, modifier = Modifier.padding(start = 12.dp, bottom = 8.dp))
PwaCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(state.user?.text("display_name").orEmpty(), style = PwaType.headline, color = colors.text)
Text(state.server, style = PwaType.caption, color = colors.secondary)
PwaTextAction("Abmelden", logout, enabled = !state.busy)
PwaTextAction("Server und lokale Sitzung entfernen", forget, enabled = !state.busy, color = colors.danger)
}
}
Text(
"Weitere Module und Einstellungen sind noch nicht nativ verfügbar. Kein Browser-Fallback.",
style = PwaType.caption,
color = colors.tertiary,
modifier = Modifier.padding(12.dp),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NativeDashboardOptions(state: NativeState, dismiss: () -> Unit, save: (Set<String>) -> Unit) {
val colors = LocalPwaPalette.current
var visible by remember { mutableStateOf(state.dashboardTiles().toSet()) }
ModalBottomSheet(
onDismissRequest = dismiss,
containerColor = colors.raised,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
shape = RoundedCornerShape(topStart = 26.dp, topEnd = 26.dp),
) {
Column(Modifier.padding(16.dp)) {
Text("Übersicht anpassen", style = PwaType.section, color = colors.text)
Text(
"Für dein Konto · wird auch in der PWA gespeichert",
style = PwaType.caption,
color = colors.secondary,
modifier = Modifier.padding(vertical = 12.dp),
)
listOf("tasks" to "Aufgaben", "calendar" to "Kalender", "shopping" to "Einkauf", "notes" to "Notizen")
.filter { state.moduleVisible(it.first) && state.widget(it.first) }.forEach { (id, label) ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Checkbox(id in visible, { checked -> visible = if (checked) visible + id else visible - id })
Text(label, style = PwaType.body)
}
}
Text(
"Nur bereits nativ unterstützte Kacheln. Andere gespeicherte Kacheln werden nicht verändert.",
style = PwaType.caption,
color = colors.tertiary,
)
PwaButton(
"Speichern",
{ save(visible) },
Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 16.dp),
!state.busy && state.preferences.has("dashboard_widgets"),
)
}
}
}
@@ -10,14 +10,47 @@ import org.junit.Test
class NativeCookiesTest { class NativeCookiesTest {
private val server = ServerAddress.parse("https://home.example:8443") private val server = ServerAddress.parse("https://home.example:8443")
@Test fun acceptsOnlySecureSameHostSessionCookies() { @Test fun acceptsOnlyAllowListedSameHostCookies() {
val jar = NativeCookies(server) val jar = NativeCookies(server)
jar.accept(listOf("yuvomi.sid=valid; Secure; HttpOnly; Path=/; Max-Age=60")) jar.accept(listOf("yuvomi.sid=valid; Secure; HttpOnly; Path=/; Max-Age=60"))
assertEquals("yuvomi.sid=valid", jar.header()) assertEquals("yuvomi.sid=valid", jar.header())
jar.accept(listOf("yuvomi.sid=insecure; Path=/", "other=unwanted; Secure", "yuvomi.sid=other-host; Domain=evil.example; Secure")) jar.accept(listOf("other=unwanted; Secure", "yuvomi.sid=other-host; Domain=evil.example; Secure"))
assertEquals("yuvomi.sid=valid", jar.header()) assertEquals("yuvomi.sid=valid", jar.header())
} }
@Test fun acceptsUpstreamDefaultCookieOnHttpsWithoutServerSecureAttribute() {
val jar = NativeCookies(server)
jar.accept(listOf("yuvomi.sid=default-cookie; HttpOnly; Path=/; Max-Age=60"))
assertEquals("yuvomi.sid=default-cookie", jar.header())
assertTrue(jar.hasSession())
val restored = NativeCookies(server, jar.serialized())
assertEquals("yuvomi.sid=default-cookie", restored.header())
assertTrue(restored.hasSession())
jar.accept(listOf("yuvomi.sid=; Path=/; Max-Age=0"))
assertFalse(jar.hasSession())
}
@Test fun emptyExpiredAndMissingCookiesAreNotAuthenticatedSessions() {
val jar = NativeCookies(server)
assertFalse(jar.hasSession())
jar.accept(listOf("yuvomi.sid=; Path=/; Max-Age=60"))
assertFalse(jar.hasSession())
val now = System.currentTimeMillis()
jar.accept(listOf("yuvomi.sid=short; Path=/; Max-Age=1"), now)
assertFalse(jar.hasSession(now + 2_000))
}
@Test fun authenticationErrorsDistinguishCredentialsCodeAndSessionHandoff() {
assertEquals(
"Benutzername oder Passwort wurden vom Server nicht akzeptiert.",
NativeApiError(401, authenticationStep = "/auth/login").userMessage(),
)
assertTrue(NativeApiError(401, authenticationStep = "/auth/2fa/verify").userMessage().contains("Bestätigungscode"))
assertTrue(NativeApiError(401, "session_cookie_missing").userMessage().contains("Sitzungscookie"))
assertTrue(NativeApiError(401, "session_not_accepted").userMessage().contains("akzeptiert die Sitzung nicht"))
assertEquals("Anmeldung oder Sitzung ist nicht mehr gültig.", NativeApiError(401).userMessage())
}
@Test fun absoluteExpiryDoesNotExtendWhenRestoring() { @Test fun absoluteExpiryDoesNotExtendWhenRestoring() {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val jar = NativeCookies(server) val jar = NativeCookies(server)
@@ -0,0 +1,101 @@
package community.yuvomi.mobile
import java.time.Instant
import java.time.LocalDate
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PwaPresentationTest {
@Test fun greetingUsesFirstNameAndUpstreamDayBoundaries() {
listOf(0 to "Abend", 4 to "Abend", 5 to "Morgen", 11 to "Morgen", 12 to "Tag", 17 to "Tag", 18 to "Abend", 23 to "Abend")
.forEach { (hour, period) -> assertEquals("Guten $period, Mobile", pwaGreeting(" Mobile Test ", hour)) }
assertEquals("Guten Tag", pwaGreeting(" ", 12))
}
@Test fun dueLabelsHandleRelativeDatesAndMalformedServerValues() {
val today = LocalDate.of(2026, 10, 21)
assertEquals("Heute fällig", pwaDueLabel("2026-10-21", today))
assertEquals("Morgen fällig", pwaDueLabel("2026-10-22", today))
assertEquals("Gestern fällig", pwaDueLabel("2026-10-20", today))
assertEquals("", pwaDueLabel("", today))
assertEquals("invalid", pwaDueLabel("invalid", today))
assertTrue(pwaDueLabel("2026-10-25", today).startsWith("25."))
}
@Test fun householdClockUsesEffectiveZoneAcrossMidnightAndDst() {
val state = NativeState(preferences = JSONObject().put("timezone", "UTC").put("timezone_effective", "Europe/Berlin"))
assertEquals(23, state.householdNow(Instant.parse("2026-10-21T21:00:00Z")).hour)
assertEquals(22, state.householdNow(Instant.parse("2026-10-26T21:00:00Z")).hour)
assertEquals(LocalDate.of(2026, 10, 22), state.householdNow(Instant.parse("2026-10-21T23:00:00Z")).toLocalDate())
assertNotNull(state.copy(preferences = JSONObject().put("timezone", "invalid")).householdNow())
}
@Test fun widgetOrderVisibilityAndPermissionFilteringArePreserved() {
val prefs =
JSONObject(
"""{"dashboard_widgets":[{"id":"notes"},{"id":"weather"},{"id":"tasks"},
{"id":"shopping","visible":false},{"id":"calendar"},{"id":"notes"}]}""",
)
val state = NativeState(preferences = prefs, permissions = JSONObject("""{"modules":{"tasks":"none"}}"""))
assertEquals(listOf("notes", "calendar"), state.dashboardTiles())
assertEquals(emptyList<String>(), state.copy(preferences = JSONObject().put("dashboard_widgets", JSONArray())).dashboardTiles())
assertEquals(pwaSupportedWidgets, NativeState().dashboardTiles())
}
@Test fun disabledHiddenAndWidgetDeniedModulesAreNotShown() {
val state = NativeState(
preferences = JSONObject("""{"disabled_modules":["tasks"],"hidden_modules":["notes"]}"""),
permissions = JSONObject("""{"widgets":{"calendar":"none"}}"""),
)
assertEquals(listOf("shopping"), state.dashboardTiles())
assertEquals(listOf(NativePage.HOME, NativePage.SHOPPING, NativePage.MORE), state.nativeTabs())
}
@Test fun navigationHonorsSupportedFavoriteOrderWithoutFakeDestinations() {
val state =
NativeState(preferences = JSONObject().put("mobile_nav_order", JSONArray(listOf("weather", "notes", "notes", "kitchen"))))
assertEquals(listOf(NativePage.HOME, NativePage.NOTES, NativePage.SHOPPING, NativePage.TASKS, NativePage.MORE), state.nativeTabs())
}
@Test fun visibilityEditsRetainUnsupportedEntriesOptionsAndInput() {
val prefs =
JSONObject(
"""{"dashboard_widgets":[{"id":"weather","visible":true,"options":{"city":"Berlin"}},
{"id":"notes","size":"2x1","options":{"category_id":4}},{"id":"tasks","visible":false}]}""",
)
val original = prefs.toString()
val result = pwaWidgetVisibility(prefs, setOf("tasks"))
assertEquals(original, prefs.toString())
assertEquals(prefs.getJSONArray("dashboard_widgets").getJSONObject(0).toString(), result.getJSONObject(0).toString())
assertFalse(result.getJSONObject(1).getBoolean("visible"))
assertEquals("2x1", result.getJSONObject(1).getString("size"))
assertEquals(4, result.getJSONObject(1).getJSONObject("options").getInt("category_id"))
assertTrue(result.getJSONObject(2).getBoolean("visible"))
assertEquals(0, pwaWidgetVisibility(JSONObject().put("dashboard_widgets", JSONArray()), emptySet()).length())
}
@Test fun enablingAnUnconfiguredSupportedWidgetAddsOnlyThatEntry() {
val result = pwaWidgetVisibility(JSONObject().put("dashboard_widgets", JSONArray()), setOf("tasks", "unknown"))
assertEquals(1, result.length())
assertEquals("tasks", result.getJSONObject(0).getString("id"))
assertTrue(result.getJSONObject(0).getBoolean("visible"))
}
@Test fun restrictedSupportedWidgetConfigRemainsUnchanged() {
val prefs = JSONObject("""{"dashboard_widgets":[{"id":"tasks","visible":true}]}""")
val result = pwaWidgetVisibility(prefs, emptySet(), setOf("notes"))
assertTrue(result.getJSONObject(0).getBoolean("visible"))
}
@Test fun labelsDoNotExposeRawInternalCategoriesOrPriorities() {
assertEquals("Sonstiges", pwaGroup("general"))
assertEquals("Gemüse", pwaGroup("Gemüse"))
assertEquals("Hoch", pwaPriority("high"))
assertEquals("", pwaPriority("unknown"))
}
}
+6 -1
View File
@@ -8,7 +8,7 @@ window around that website. Both shipping entry points now render native views:
| Screens and navigation | Jetpack Compose / Material 3 primitives, Yuvomi tokens | SwiftUI, Yuvomi tokens | | Screens and navigation | Jetpack Compose / Material 3 primitives, Yuvomi tokens | SwiftUI, Yuvomi tokens |
| Application state | AndroidViewModel + Compose state | MainActor ObservableObject | | Application state | AndroidViewModel + Compose state | MainActor ObservableObject |
| JSON API | HttpsURLConnection, serialized worker | URLSession actor, bounded streaming delegate | | JSON API | HttpsURLConnection, serialized worker | URLSession actor, bounded streaming delegate |
| Session ownership | Explicit origin-bound secure-cookie jar | Private ephemeral URLSession cookie storage | | Session ownership | Explicit origin-bound HTTPS-only cookie jar | Private ephemeral URLSession cookie storage |
| Persistent cookies | AES-GCM, AndroidKeyStore key, origin as authenticated data | Keychain, WhenUnlockedThisDeviceOnly, origin-scoped account | | Persistent cookies | AES-GCM, AndroidKeyStore key, origin as authenticated data | Keychain, WhenUnlockedThisDeviceOnly, origin-scoped account |
| Server logic | Existing unmodified Yuvomi API | Existing unmodified Yuvomi API | | Server logic | Existing unmodified Yuvomi API | Existing unmodified Yuvomi API |
@@ -34,6 +34,11 @@ navigation destinations. The other modules remain unfinished.
Session-only cookies are not converted to long-lived cookies. Session-only cookies are not converted to long-lived cookies.
- Native UI mutations wait for the server result. Permissions remain enforced - Native UI mutations wait for the server result. Permissions remain enforced
server-side; sparse module/widget permissions also control native affordances. server-side; sparse module/widget permissions also control native affordances.
- Android's native jar enforces HTTPS itself rather than rejecting a valid server
cookie solely because its `Secure` attribute is absent. This supports upstream's
default session settings behind HTTPS proxies without permitting HTTP or
cross-origin cookie replay. Browser cookie configuration remains the server
operator's responsibility; `SESSION_SECURE=true` is recommended for HTTPS sites.
- Local removal clears the native cookie vault and in-memory records. It is not - Local removal clears the native cookie vault and in-memory records. It is not
remote revocation. Logout attempts server revocation and still erases the local remote revocation. Logout attempts server revocation and still erases the local
session if the server cannot be reached. session if the server cannot be reached.
+6
View File
@@ -62,6 +62,7 @@ git clone --filter=blob:none https://github.com/ulsklyc/yuvomi.git /path/to/upst
git -C /path/to/upstream checkout 10bfef7686ed7303b4c74c7fcc55a5eb16356c9d git -C /path/to/upstream checkout 10bfef7686ed7303b4c74c7fcc55a5eb16356c9d
npm ci --omit=dev --prefix /path/to/upstream npm ci --omit=dev --prefix /path/to/upstream
python3 scripts/test-integration.py /path/to/upstream --android python3 scripts/test-integration.py /path/to/upstream --android
python3 scripts/test-integration.py /path/to/upstream --android --cookie-without-secure
``` ```
Requires Node 22.14+ and a running emulator. The harness creates and removes its Requires Node 22.14+ and a running emulator. The harness creates and removes its
@@ -71,6 +72,11 @@ Keep test reports private if you customize the harness with real data (not advis
The native test calls the real API through native controls and saves a synthetic The native test calls the real API through native controls and saves a synthetic
dashboard screenshot under `app/build/outputs/native-screenshots/`. It does not dashboard screenshot under `app/build/outputs/native-screenshots/`. It does not
demonstrate native SSO merely because the protocol fixture also tests OIDC. demonstrate native SSO merely because the protocol fixture also tests OIDC.
Both commands retain HTTPS and system/test-CA validation; the second reproduces
upstream's default `SESSION_SECURE=false` cookie attributes behind an HTTPS proxy.
The native jar must retain that session while continuing to forbid HTTP and
cross-origin replay. The scenario also rejects a wrong password, then retries
successfully without retaining the old error.
The test writes through `PlatformTestStorageRegistry`; AGP collects the file in The test writes through `PlatformTestStorageRegistry`; AGP collects the file in
`outputs/connected_android_test_additional_output/integrationAndroidTest/` before `outputs/connected_android_test_additional_output/integrationAndroidTest/` before
uninstalling the fixture. The harness rejects missing/ambiguous output and removes uninstalling the fixture. The harness rejects missing/ambiguous output and removes
+13 -1
View File
@@ -46,7 +46,19 @@ Before the first JavaScript action, an inline shell bootstrap installs verified
native Node 22.22.1 into a versioned runner tool cache and exports its bin directory native Node 22.22.1 into a versioned runner tool cache and exports its bin directory
through `GITHUB_PATH`. It needs no Homebrew/root access and remains available to through `GITHUB_PATH`. It needs no Homebrew/root access and remains available to
action post-cleanup. This addresses run #4's `Cannot find: node in PATH` failure; action post-cleanup. This addresses run #4's `Cannot find: node in PATH` failure;
the modified workflow itself still needs a real Mac run. native bootstrap and checkout were verified in Mac job 1062 (run 661/#6).
That job subsequently stalled in Apple-tool preflight after printing Xcode 27.1.
The preflight now prints each tool probe and bounds it to 60 seconds using Python
(`scripts/check-apple-tools.py`), so the next run identifies the unresponsive
command rather than hanging silently. Follow-up job 1066 (run 663/#8) verified the
diagnostic: Xcode and Swift lookup pass, but `xcrun simctl list devices available`
times out. This is a runner prerequisite failure, not an iOS application test.
If an Apple probe stalls, run the named command in the runner user's interactive
login session, check the selected full Xcode, finish its first-launch components
and confirm CoreSimulator/runtimes work for that user. CI does not accept licenses,
change `xcode-select`, install runtimes or kill host simulator services. The runner
administrator must repair those prerequisites before native Apple validation.
XcodeGen's pinned, checksum-verified binary includes native arm64 and x86_64 XcodeGen's pinned, checksum-verified binary includes native arm64 and x86_64
slices; Rosetta/Homebrew installation is not required by the workflow. Build, slices; Rosetta/Homebrew installation is not required by the workflow. Build,
+88 -4
View File
@@ -13,6 +13,79 @@ Read [native parity](native-parity.md), [architecture](architecture.md), and
shopping slices exist, but **no module has accepted full visual/functional parity**. shopping slices exist, but **no module has accepted full visual/functional parity**.
The remaining pages/settings/auth/file/offline/notification work is explicit. The remaining pages/settings/auth/file/offline/notification work is explicit.
### Android design follow-up: 0.2.2 native preview / code 4
The user confirmed the login fix and requested the PWA design 1:1. The Android
slice now uses PWA-derived native primitives in `PwaDesign.kt`, `PwaIcons.kt`,
`PwaShell.kt`, `NativePages.kt` and `NativeForms.kt`, instead of the first generic
Material layout. `PwaPresentation.kt` handles greeting/timezone/config filtering;
the controller reads personal preferences and auto-selects the first shopping list.
The confirmed cookie/session transport was not changed.
Read [design comparison](native-design.md) before making visual claims. External
Playwright 1.55.1/Chromium 140 tooling captures the actual unmodified PWA; the
emulator exports ten light/dark native views with viewport metadata. Match the
360 × 592 content viewport and Android variable Roboto, not the system-bar-inclusive
root. The earlier clipped tab labels were caused by per-tab circular clipping.
The apparent second white capsule segment occurred where subsequent dashboard
content remained visible beneath the translucent navigation; inspect actual
matched captures rather than treating any visible card edge as a second bar.
Reference generation uses `/tmp/opencode/yuvomi-visual-tools/node_modules/playwright`,
`/tmp/opencode/yuvomi-visual-tools/fonts.conf` and persistent browsers at
`/home/neste/yuvomi-tools/playwright-browsers`. The temporary font config points to
the emulator's variable `/system/fonts/Roboto-Regular.ttf`; it includes all weights.
Set `PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64` on this Ubuntu 26 host.
Reference output is ignored under `artifacts/visual/pwa-360x592`; native screenshots
are in the external build's `app/outputs/native-screenshots`. The side-by-side
compositor is tooling only; screenshots are evidence, not automated parity approval.
The committed source `7ac9882` also passed a separate clean-archive build in
`/tmp/opencode/yuvomi-design-clean-1fknxqpt`, outputs at
`/home/neste/yuvomi-design-clean-build-7ac9882`. All three inspected native packages
are collected in ignored `artifacts/android/`; the installable CI debug APK is a
separate artifact with its own signature/checksum, not assumed identical.
Gitea run 670/build job 1079 succeeded at `7ac9882`. The actual 0.2.2/code-4 CI APK
was downloaded, signature/version/contents checked, installed and cold-launched
on `emulator-5554`; link and SHA-256 are in `validation.md`. It is now the installed
local preview, with a certificate different from the local Gradle debug key.
Remove only that known synthetic preview/use a fresh AVD before local device tests
if an install mismatch occurs; zero tests must still fail verification.
Remote emulator jobs 1080/1081 still report inaccessible `/dev/kvm`. Signing remains
paused, and the complete Android run is not green despite its successful build job.
iOS is unchanged by this design pass. Later inspection of run 669/job 1078 at
`bd0e4cc` verified 16 portable tests, 16 simulator XCTest tests, one onboarding UI
test and an unsigned device archive. The historical CoreSimulator timeout is no
longer the current blocker; see `validation.md` and the retained external job log.
Run 671/job 1082 at `7ac9882` subsequently repeated those successful Apple checks;
the actual log was downloaded and its test counts/archive markers inspected.
This is not iOS native API/Keychain/device or visual-parity acceptance.
No module has accepted full functional/visual parity, the rest
of the inventory remains open, and signing stays paused. Continue refinement from
actual comparison images, including non-default font sizes/devices and native
module functionality; do not declare the whole user request finished from this
Android slice alone.
### Android login follow-up: 0.2.1 native preview / code 3
A user reported immediate HTTP-401 login failure although the same direct password
login works in the browser. Reproduced against unmodified upstream by setting its
existing `SESSION_SECURE=false` default behind the HTTPS fixture: the old Android
jar discarded the cookie and the next authenticated request failed. The fix keeps
all native cookies HTTPS/origin-bound independent of that server attribute;
certificate checks, redirect refusal, encryption and expiry are unchanged. Login
errors now distinguish credential/code rejection from session-cookie problems and
a successful retry clears the old error. Both secure/default-cookie test modes
are in CI; see `login-troubleshooting.md` and the latest validation section.
The fix is source `4b483c2`; local 13 JVM, both 7-test cookie modes, 6 ordinary
native UI tests, lint and all packages pass. Gitea build job 1071/run 666 (#11)
passed and published the actual 0.2.1/code-3 APK; downloaded, verified and installed/
launched on `emulator-5554`. Exact link/checksum are in `validation.md`.
That CI-signed debug APK is now installed on the local emulator. Its certificate
differs from the local Gradle debug key: before another local device-test run,
remove only this synthetic preview package or use a fresh AVD. An install failure
with zero tests must not be reported as a passing instrumentation run.
The owner-authorized signing key and four Gitea secrets were created before this The owner-authorized signing key and four Gitea secrets were created before this
correction. Their private backup is `/home/neste/yuvomi-signing/initial-20261021`. correction. Their private backup is `/home/neste/yuvomi-signing/initial-20261021`.
Do not print its contents. Uncommitted signing changes are preserved in Git stash Do not print its contents. Uncommitted signing changes are preserved in Git stash
@@ -34,7 +107,7 @@ is not typechecked by Linux. Gitea's Mac job now bootstraps pinned Node before
checkout, but actual Xcode results still need inspection. Ubuntu emulator jobs checkout, but actual Xcode results still need inspection. Ubuntu emulator jobs
continue requiring accessible KVM; do not weaken or fake that evidence. continue requiring accessible KVM; do not weaken or fake that evidence.
Latest local native evidence: 10 JVM tests, 6 API-35 Compose tests, 7 real-server Initial 0.2.0 local native evidence: 10 JVM tests, 6 API-35 Compose tests, 7 real-server
instrumentation tests (including those same 6), both lint variants and native instrumentation tests (including those same 6), both lint variants and native
debug/unsigned release APK/AAB builds pass. Sixteen portable Swift tests and strict debug/unsigned release APK/AAB builds pass. Sixteen portable Swift tests and strict
format/parsing pass. Screenshot export now uses AndroidX test storage/AGP output format/parsing pass. Screenshot export now uses AndroidX test storage/AGP output
@@ -42,7 +115,17 @@ collection before uninstall; the PNG was opened and confirmed nonblank. Five new
output-regression tests guard missing, invalid, ambiguous and stale screenshots. output-regression tests guard missing, invalid, ambiguous and stale screenshots.
See `validation.md` for logs and limitations. No Apple SDK result is implied. See `validation.md` for logs and limitations. No Apple SDK result is implied.
The next Mac/Ubuntu workflow execution still needs inspection after pushing. Android build job 1059 in global run 660 (#5) succeeded; the actual native debug
artifact was downloaded, checked and installed/launched on `emulator-5554`.
Jobs 1060/1061 still fail KVM preflight. Mac job 1062 in run 661 (#6) passed native
Node bootstrap and checkout, printed Xcode 27.1, then stalled in Apple-tool
preflight for at least 20 minutes. The new `check-apple-tools.py` prints each
command and imposes a 60-second deadline; fourteen runner/probe regressions pass.
Follow-up Mac job 1066 in run 663 (#8) identifies the exact blocker:
`xcrun simctl list devices available` exceeds 60 seconds. Xcode version and Swift
lookup pass. An administrator must fix the runner user's CoreSimulator/login
session/Xcode prerequisites without weakening the check. App compilation/tests
and archive have not run. No remote host license/settings were changed.
Rebuild/collect from a clean checkout; do not present cached wrapper artifacts as Rebuild/collect from a clean checkout; do not present cached wrapper artifacts as
the native preview. Keep the signing stash parked. the native preview. Keep the signing stash parked.
@@ -114,8 +197,9 @@ validation must happen on macOS with Xcode and real supported devices.
## Highest-priority external work ## Highest-priority external work
1. Typecheck/build the iOS app against Apple SDKs, run its actual XCTest/UI tests, 1. Extend the now-successful Apple SDK/simulator/archive validation beyond the
and produce an unsigned archive. Fix any failures; none of these ran locally. basic onboarding UI test to authenticated native API flows, Keychain runtime,
visual comparisons and physical devices. These did not run on Linux.
2. Run the API 37 permission grant/denial/revocation scenarios on a working image 2. Run the API 37 permission grant/denial/revocation scenarios on a working image
or device, plus the minimum-supported Android and current WebView matrix. or device, plus the minimum-supported Android and current WebView matrix.
3. Review iOS unknown-length download disk-pressure handling before release. 3. Review iOS unknown-length download disk-pressure handling before release.
Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

+56
View File
@@ -0,0 +1,56 @@
# Android native login: 0.2.1 preview fix
## Reproduced compatibility defect
The Android 0.2.0 native preview could show “Anmeldung oder Sitzung ist nicht mehr
gültig” even when the identical direct username/password login worked in the PWA.
Upstream defaults to `SESSION_SECURE=false`; an HTTPS reverse proxy does not by
itself change this cookie attribute. The old native jar rejected the session
cookie without `Secure`, so the successful login was followed by an unauthenticated
API request. This was an app compatibility defect, not evidence of a bad password.
Android 0.2.1 native preview (version code 3) keeps the cookie in its existing
HTTPS-only, origin/port-bound jar. It still never makes HTTP requests, follows
redirects or shares cookies with another server. System TLS validation,
AndroidKeyStore encryption and absolute expiry remain unchanged. The native client
does not depend on weakening the server or changing its configuration. For browser
security, server operators should still use `SESSION_SECURE=true` on HTTPS sites.
[Download the fixed 0.2.1 preview APK ZIP](https://git.nesterovic.cc/nessi/Yuvomi-App/actions/runs/666/artifacts/android-native-debug-apk)
(verified Gitea build job 1071; repository access required, 14-day retention).
Extract `app-debug.apk` and install it. The downloaded artifact's version and
signature were checked and installation/launch tested on the authorized emulator.
## Error distinctions
- Rejected username/password: the server rejected `/auth/login` with HTTP 401.
- Invalid/expired second-factor step: the verification endpoint rejected the code
or its pending session. Restart login if necessary.
- Login confirmed but no usable cookie: inspect server/reverse-proxy cookie
configuration; no password-reset claim is made.
- Login confirmed but session rejected: inspect server/proxy session handling.
The app never displays raw cookie values, credentials or arbitrary server response
bodies as diagnostics. A failed attempt's message is cleared after successful retry.
If the updated app still fails, report the exact new message, app/server version,
whether the direct browser password login works and whether 2FA is enabled.
**Do not send passwords, session cookies, recovery codes or authentication tokens.**
## Reproduction and regression evidence
```sh
python3 scripts/test-integration.py /path/to/pinned-upstream --android --cookie-without-secure
python3 scripts/test-integration.py /path/to/pinned-upstream --android
```
The first mode fails with the old native jar despite successful protocol-level
browser login. After the fix, it passes native login, a wrong-password/correct-retry
sequence, dashboard/recreation, task/note/shopping operations and logout. The normal
Secure-cookie mode remains required too. These use synthetic temporary records and
the unmodified pinned upstream, not the user's server or credentials.
Development APKs from different CI jobs can have different debug signing keys.
If Android rejects an update as signature-incompatible, uninstall the old preview
before installing the new one. This clears local app/session data and requires a
fresh login; server household records are not deleted. Production signing remains
paused and full native PWA parity is not implied by this fix.
+14 -5
View File
@@ -4,6 +4,11 @@
This report closes the initial native rebuild slice, not the requested complete This report closes the initial native rebuild slice, not the requested complete
1:1 replacement. No module has accepted functional and visual parity. 1:1 replacement. No module has accepted functional and visual parity.
**Follow-up:** Android 0.2.1 native preview (code 3) fixes a reproduced session-cookie
login compatibility defect. See [login troubleshooting](login-troubleshooting.md)
and the newer [validation evidence](validation.md); the original 0.2.0 results
below remain historical evidence, not a replacement for the fixed APK.
## 1. Outcome ## 1. Outcome
The rejected embedded-browser application has been replaced by genuine native The rejected embedded-browser application has been replaced by genuine native
@@ -49,7 +54,7 @@ Human security review and additional device/role/error-path coverage remain open
10 JVM tests, 6 native API-35 UI tests, the 7-test real-server suite (including 10 JVM tests, 6 native API-35 UI tests, the 7-test real-server suite (including
those same 6), both Android lint variants and package builds pass. Swift has 16 those same 6), both Android lint variants and package builds pass. Swift has 16
portable passes plus strict formatting/parsing. 23 script regressions, repository portable passes plus strict formatting/parsing. 27 script regressions, repository
checks, workflow lint and the 266-coordinate OSV scan pass. See checks, workflow lint and the 266-coordinate OSV scan pass. See
[validation](validation.md) for evidence, failures corrected and exact limitations. [validation](validation.md) for evidence, failures corrected and exact limitations.
@@ -62,10 +67,14 @@ state. Only the development-signed debug APK is installable as provided.
## 10. Gitea CI ## 10. Gitea CI
The Android workflow offers `android-native-debug-apk`; the Mac workflow bootstraps Android build job 1059 succeeded and uploaded `android-native-debug-apk`. The actual
verified ARM64 Node before checkout. Actual artifact publication and Apple build artifact was downloaded, signature/version checked, installed and launched on the
results require remote-run inspection. Remote Ubuntu emulator jobs need KVM access; authorized local emulator. [Download the verified artifact](https://git.nesterovic.cc/nessi/Yuvomi-App/actions/runs/660/artifacts/android-native-debug-apk).
local emulator success is not a substitute for a passing remote matrix. Mac Node bootstrap, checkout and Swift lookup now succeed, but
`xcrun simctl list devices available` times out after 60 seconds in follow-up job
1066. The runner's CoreSimulator availability needs administrator repair. The remote emulator matrix
still fails missing-KVM preflight, not native tests. No green whole-pipeline or
Apple application validation is claimed. See [validation](validation.md).
## 11. Signing and publication ## 11. Signing and publication
+93
View File
@@ -0,0 +1,93 @@
# Native PWA design comparison
The target is the actual unmodified Yuvomi 2.78.0 PWA, revision
`10bfef7686ed7303b4c74c7fcc55a5eb16356c9d`, not a new Material reinterpretation.
Android 0.2.2/code 4 is a design pass on the existing native slice. **No page has
accepted 1:1 visual/functional parity.** iOS has not received this Android pass.
## Implementation
`PwaDesign.kt` maps measured palette/type roles, field shapes and module seals.
`PwaIcons.kt` draws upstream module geometry on the native canvas.
`PwaShell.kt` provides the capsule/FAB and native sheets; API 31+ uses an in-memory
recorded native drawing layer for the blurred backdrop, with opaque older-API
fallback. No HTML, JavaScript or browser surface is shipped.
`NativePages.kt` / `NativeForms.kt` compose the supported native screens.
Personal preferences and shopping lists are loaded from the real API. Widget
visibility writes retain other stored widget entries/options; they are personal,
not household default edits. Entries hidden/denied to this native UI are retained;
new supported selections can be added. Saving is disabled if preference loading
did not provide a known configuration, rather than overwriting unknown data.
## Reproducible comparison
The fixture has synthetic account/data only: one task, pinned note, list/item,
four widgets (`tasks`, `calendar`, `shopping`, `notes`) and three supported mobile
favorites (`tasks`, `kitchen`, `notes`). This deliberate **supported-slice** setup
is not the PWA's complete/default dashboard or a full navigation comparison.
Native integration exports `login`, `dashboard`, `tasks`, `notes`, `shopping`, each
in light/dark, plus the compatible `native-dashboard.png` and `viewport.json`.
Capture clears `FLAG_SECURE` only in that temporary-fixture test and restores it in
`finally`. It waits for IME/window settling and crops `native-screen`, excluding
system bars. Ordinary builds remain screenshot-protected.
The collector requires the whole allow-listed gallery from exactly one device,
rejects missing/invalid/ambiguous output and removes only known stale evidence.
Observed API-35 content viewport: **720 × 1184 px, density 2, fontScale 1**, hence
360 × 592 CSS pixels for the browser reference. Root bounds are
`[0,48,720,1232]` on the 720 × 1280 emulator. Do not compare a full native root
image against a 360 × 640 PWA content viewport.
```sh
ANDROID_SERIAL=emulator-5554 python3 scripts/test-integration.py /path/to/pinned-upstream --android --cookie-without-secure
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 \
PLAYWRIGHT_BROWSERS_PATH=/path/to/external/browsers \
FONTCONFIG_FILE=/path/to/isolated/android-roboto/fonts.conf \
python3 scripts/capture-pwa-reference.py /path/to/pinned-upstream \
--playwright-module /path/to/external/node_modules/playwright \
--width 360 --height 592 --output artifacts/visual/pwa-360x592
node scripts/compare-native-pwa.mjs /path/to/external/node_modules/playwright \
/path/to/build/app/outputs/native-screenshots artifacts/visual/pwa-360x592 \
artifacts/visual/comparisons
```
Playwright/Chromium and the emulator's variable Roboto are external tooling, not
application dependencies. The reference recorder captures real browser PNGs and
computed styles, without editing upstream files. It uses a temporary loopback-only
HTTPS fixture, blocks external browser requests and does not export credentials,
cookies, generated certificates or keys. Dates/greetings and initial account colors
can vary; compare those separately from geometry. Native integration keeps OIDC
enabled for the redirect-refusal regression, whereas the password-login reference
disables it; their extra SSO controls are not a valid exact login comparison.
The comparison compositor refuses mismatched viewport/image sizes or font scale,
places the actual captures side by side and records their SHA-256 hashes. It does
not replace either screenshot, normalize away UI differences or assert acceptance.
## Explicit remaining differences
- Full calendar navigation and its widget action are absent, not fake buttons.
- Native SSO/recovery/invites and most modules/settings remain unimplemented.
- Advanced task categories/assignment/statuses, note rendering/categories and
shopping quick-add/details do not yet match all PWA interactions.
- Card internals, utility icons, shadows/glass and form geometry still require
visual refinement and broader device/font-size/accessibility testing.
- The three supported favorites stand in for unavailable native destinations;
this is not full preference/navigation parity.
- The unchanged iOS slice now has a verified Apple simulator build/test/archive
run, but no new design comparison or native real-server parity acceptance;
see [validation](validation.md).
Screenshot existence and passing CRUD tests are evidence, **not visual acceptance**.
See [native parity](native-parity.md) for the complete scope.
## Reviewed examples from this pass
Left is the real PWA, right the actual native capture. Remaining differences are
deliberately visible, including the absent calendar action, widget spacing,
utility icon/control differences and upstream's dashboard-customization hint.
- [Dashboard, light](images/dashboard-light-comparison.png)
- [Dashboard, dark](images/dashboard-dark-comparison.png)
- [Tasks, light](images/tasks-light-comparison.png)
- [Notes, dark](images/notes-dark-comparison.png)
+5 -1
View File
@@ -17,7 +17,11 @@ Use these states honestly:
- **Slice**: real native UI/API interaction exists, with the listed limitations. - **Slice**: real native UI/API interaction exists, with the listed limitations.
- **Pending**: not implemented. No fake screen, raw-JSON editor or WebView fallback. - **Pending**: not implemented. No fake screen, raw-JSON editor or WebView fallback.
- **Parity accepted**: requires functional and visual evidence on both platforms. - **Parity accepted**: requires functional and visual evidence on both platforms.
**No module currently has this status.** **No module currently has this status.**
Android 0.2.2 has a PWA-derived design pass on the supported slice, with real
reference/native light/dark captures. See [design comparison](native-design.md).
This does not change the acceptance status or certify the unchanged iOS UI.
## Page inventory ## Page inventory
+158 -7
View File
@@ -8,7 +8,129 @@ tests are in `androidTestNative` / `androidTestNativeIntegration`, JVM native po
tests, and Swift production contracts/transport tests. Archived WebView tests are tests, and Swift production contracts/transport tests. Archived WebView tests are
not included in the native suites or counted toward native completion. not included in the native suites or counted toward native completion.
## Executed locally — 2026-10-21 ## Android 0.2.2 / code 4: PWA-derived design pass
Local API-35 validation after the design pass:
| Check | Observed result |
| --- | --- |
| JVM policy/presentation tests | 23 passed, zero failures/errors/skips |
| Native Compose UI/security/presentation tests | 9 passed, zero failures/errors/skips |
| Real upstream, default non-Secure cookie over HTTPS | 10 passed, zero failures/errors/skips |
| Real upstream, Secure cookie | 10 passed, zero failures/errors/skips |
| Android lint and packages | Both lint variants; debug and unsigned release APK/AAB passed |
| Screenshot evidence | Ten light/dark native views, viewport metadata, ten actual-PWA references and ten matched-size side-by-side images |
| Script checks | 9 screenshot-collection, 8 JUnit-verifier and 14 runner/probe regressions passed; repository/Python/Node/workflow checks passed |
| Dependency scan | OSV listed no advisories for 266 locked/direct Maven coordinates/plugins |
The first ordinary device-test attempt ran **zero tests** because a leftover
CI-signed 0.2.1 preview had a different debug certificate. The verifier rejected
that run despite Gradle's successful exit. AGP cleanup removed the package; the
clean rerun executed all nine tests. Both complete integration harnesses then
exited successfully and retained the rejected-password/successful-retry regression.
Logs: `/home/neste/yuvomi-design-build-final.log`,
`/home/neste/yuvomi-design-cookie-final.log`,
`/home/neste/yuvomi-design-secure-final.log`.
The [design comparison](native-design.md) includes reviewed synthetic images and
documents the exact 360 × 592 content viewport and intentional fixture scope.
**This is not accepted 1:1 parity.** iOS was not changed in this design pass.
Remote Android KVM prerequisites and production signing remain open. The earlier
Mac blocker has since cleared, as verified below.
A separate `git archive` checkout of committed source `7ac9882` was rebuilt at
`/tmp/opencode/yuvomi-design-clean-1fknxqpt`, with outputs under
`/home/neste/yuvomi-design-clean-build-7ac9882`. JVM tests, both lint variants and
debug/unsigned release APK/AAB builds succeeded (`/home/neste/yuvomi-design-clean-build.log`).
The artifact collector verified version/code, debug signature, unsigned release
outputs, production TLS-only/system-CA policy and absence of test certificates or
private signing files. Local copies are in ignored `artifacts/android/`. Collection
records the documentation-only dirty worktree honestly; the build itself used the
clean committed-source archive, not those later documentation edits.
### Downloaded and installed CI artifact
Gitea Android run **670**, build job **1079**, source `7ac9882`: the build/test/lint,
repository/script/format/dependency checks and artifact upload succeeded. The
actual ZIP was downloaded; its sole `app-debug.apk` is
`0.2.2-native-preview` / code **4**, and `apksigner` verified its signature. No
fixture certificate or private signing files were present. The downloaded APK was
then installed and cold-launched successfully on authorized `emulator-5554`.
- [Download Android 0.2.2 native preview](https://git.nesterovic.cc/nessi/Yuvomi-App/actions/runs/670/artifacts/android-native-debug-apk)
- APK SHA-256: `6efea6c2fbafc0702f6cd8c665817dd93a1d63590b00da4def09d246df900e44`
- Local downloaded copy/verification: ignored `artifacts/android-ci-670/`.
- The CI debug certificate differs from the local development certificate. Only
the known synthetic local preview was removed for this installation. A user
whose older preview has a different debug certificate may need to uninstall it
before installing this APK and then log in again; server data is not deleted.
The whole Android run is **not green**: emulator jobs **1080/1081** failed preflight
because `/dev/kvm` is not exposed/read-write accessible inside their jobs. Their
logs explicitly report this prerequisite; no remote emulator pass is claimed.
All device-test results above are the actual local API-35 executions.
### Verified Apple runner recovery
Inspection of Gitea run 669/job 1078 at `bd0e4cc` found an actual successful Apple
run, not just green bootstrap steps. The downloaded job log contains:
- Successful bounded `simctl` preflight and native app simulator compilation.
- 16 portable tests, then 16 simulator XCTest tests with zero failures.
- One `OnboardingTests.testRejectsCleartextWithoutNetwork` UI test, zero failures;
it checks cleartext rejection and absence of a WebView in the native hierarchy.
- `** TEST SUCCEEDED **` and `** ARCHIVE SUCCEEDED **` for the explicitly unsigned
generic-iOS device archive.
Log retained outside Git: `/home/neste/yuvomi-ios-job-1078.log`. This supersedes the
earlier CoreSimulator timeout as a **current** blocker, while leaving its historical
diagnosis intact. It is not a native login/CRUD, Keychain runtime, real-device or
visual-parity certification, nor an installable signed iOS distribution.
The current design source `7ac9882` also completed the same Apple checks in run
671/job 1082. Its downloaded log again contains the 16-test XCTest and one-test
onboarding successes plus `TEST SUCCEEDED` / `ARCHIVE SUCCEEDED` markers
(`/home/neste/yuvomi-ios-job-1082.log`). The iOS UI itself is unchanged.
## Android 0.2.1 / code 3: reproduced login regression
The user reported immediate login failure despite a working direct browser login.
The root cause was reproduced using upstream's `SESSION_SECURE=false` default over
HTTPS: browser/protocol login succeeded, but the old Android jar discarded the
session cookie and its next authenticated API request failed. The fixed jar binds
all cookies to HTTPS itself instead of requiring this optional server attribute.
HTTP, cross-origin requests/redirects, invalid certificates, plaintext persistence
and session-only cookie persistence remain disallowed.
- Before-fix failure retained in `/home/neste/yuvomi-login-cookie-before.log` and
`/home/neste/yuvomi-login-cookie-before.xml` (synthetic fixture only).
- After-fix upstream-default cookie mode: 7 instrumentation tests passed,
zero failures/skips; complete harness exited successfully.
- Secure-cookie mode: the same 7 tests passed, zero failures/skips.
- Both modes test a rejected password followed by successful retry with no stale
error, native dashboard/recreation, task/note/shopping calls and logout.
- 13 JVM tests passed, including default-cookie persistence/deletion, expired/empty
session detection and separate credential/2FA/session-handoff error messages.
- Both lint variants, debug/unsigned release APK/AAB builds and the 6-test ordinary
native UI suite passed. Its first attempt hit a leftover CI-debug certificate
mismatch and produced zero tests; the verifier correctly rejected it. After
confirming the fixture package was absent, the rerun executed all 6 with no
failures/skips (`/home/neste/yuvomi-login-debug-retry.log`).
- Logs: `/home/neste/yuvomi-login-cookie-after.log`,
`/home/neste/yuvomi-login-secure-after.log`, `/home/neste/yuvomi-login-unit.log`,
`/home/neste/yuvomi-login-validation.log`.
- Gitea run #11/global 666, build job 1071 at `4b483c2`: packages, JVM/lint,
repository/script/format checks, OSV and artifact uploads all succeeded.
- [Download fixed Android 0.2.1/code 3](https://git.nesterovic.cc/nessi/Yuvomi-App/actions/runs/666/artifacts/android-native-debug-apk).
The ZIP was downloaded and its sole APK version/v2 signature checked; it was
installed and launched on the authorized local emulator. APK SHA-256:
`a769118f0bd16988f76a8ee95c0aa0c5e248104e8ea529eee2e0c83d78b6ca5a`.
This does not certify the still-blocked remote emulator matrix or Apple pipeline.
See [login troubleshooting](login-troubleshooting.md). This reproduces a matching
compatibility defect without accessing the user's server or credentials; it does
not claim inspection of that particular server's configuration.
## Initial 0.2.0 preview evidence — 2026-10-21
| Check | Observed result | | Check | Observed result |
| --- | --- | | --- | --- |
@@ -21,7 +143,7 @@ not included in the native suites or counted toward native completion.
| Android packages | Debug APK and unsigned release APK/AAB built successfully | | Android packages | Debug APK and unsigned release APK/AAB built successfully |
| Swift portable suite | 16 passed; actual Foundation transport/contracts and URL/metadata policy, not Apple UI/Keychain execution | | Swift portable suite | 16 passed; actual Foundation transport/contracts and URL/metadata policy, not Apple UI/Keychain execution |
| Swift formatting/parsing | Strict format lint and parsing passed, including Apple-only sources | | Swift formatting/parsing | Strict format lint and parsing passed, including Apple-only sources |
| Script regressions | 10 runner-preflight, 8 JUnit-evidence and 5 screenshot-output tests passed | | Script regressions | 14 runner-preflight/Apple-probe, 8 JUnit-evidence and 5 screenshot-output tests passed |
| Repository/workflows | Source invariants, Python compilation, diff whitespace and actionlint for all 3 Gitea workflows passed | | Repository/workflows | Source invariants, Python compilation, diff whitespace and actionlint for all 3 Gitea workflows passed |
| OSV | No listed advisories for 266 locked/direct Maven coordinates and plugins; not a security audit | | OSV | No listed advisories for 266 locked/direct Maven coordinates and plugins; not a security audit |
@@ -51,10 +173,39 @@ Evidence paths in the local external build directory:
`/home/neste/yuvomi-native-integration-final.log`, `/home/neste/yuvomi-native-integration-final.log`,
`/home/neste/yuvomi-native-swift.log`. `/home/neste/yuvomi-native-swift.log`.
No Apple SDK application build, simulator UI test or physical-device result is ## Observed Gitea results for source `40a7962`
claimed from these Linux runs. The modified Mac bootstrap and remote APK upload
still require inspection of the next actual Gitea run. Prior remote emulator jobs - Android run #5 (global run 660), build job 1059: **success**, including packages,
failed KVM preflight; the prerequisite was not weakened or counted as passing. JVM/lint, formatting/repository/script checks, OSV and both artifact uploads.
- The actual `android-native-debug-apk` ZIP was downloaded. It contains exactly
`app-debug.apk`, version `0.2.0-native-preview`/code 2; APK v2 signature verified,
no fixture certificate/private-key files, installed and launched on the local
authorized API-35 emulator. SHA-256 of the downloaded APK:
`48d58ba5663b2e4fe563064efc567732e8ea35588cb610660f60406e82ccf610`.
Its ephemeral CI debug certificate differs from the local development key, so
its checksum intentionally differs from the locally collected APK.
- [Download the verified native debug artifact](https://git.nesterovic.cc/nessi/Yuvomi-App/actions/runs/660/artifacts/android-native-debug-apk)
(repository access required; normal 14-day artifact retention applies).
- Remote jobs 1060/1061 (API 35/37.0): **failed preflight** because `/dev/kvm` was
not exposed/writable. They executed no emulator tests. The matrix is not green.
- iOS run #6 (global 661), job 1062: verified native Node bootstrap and checkout
**succeeded**. Xcode 27.1/build 27A9275 printed, but the subsequent Apple-tool
preflight did not return during a 20-minute observation. No SwiftUI compile or
simulator/XCTest/Keychain/device success is claimed. Bounded, named tool probes
now fail with actionable diagnostics instead of silently hanging for the job's
full hour.
- Follow-up iOS run #8 (global 663), job 1066 at `92dbd0e`: Node, checkout,
`xcodebuild -version` and `xcrun --find swift` passed. The exact blocker is
**`xcrun simctl list devices available`**, which exceeded its 60-second deadline.
Preflight correctly failed; application builds/tests/archive were skipped, not
passed. The runner administrator must repair CoreSimulator availability for the
runner user before Apple application validation can proceed.
At that point no Apple SDK application build or simulator UI result was available
from the Linux runs or stalled Mac job. The later verified run 669 above supersedes
that limitation for compilation/basic simulator tests/archive only. No physical-
device result is claimed. Runner prerequisites were not weakened, and this agent
did not change host license/settings.
## Commands ## Commands
@@ -75,7 +226,7 @@ devices are not test targets.
## External gates ## External gates
Apple SDK builds, real supported devices/current engines, Android API 37, native Broad Apple UI/runtime coverage, real supported devices, Android API 37, native
SSO/reset/invite protocols, permission/role cases, files/offline/notifications, SSO/reset/invite protocols, permission/role cases, files/offline/notifications,
complete module and settings parity, visual baselines, accessibility and human complete module and settings parity, visual baselines, accessibility and human
security review remain open. Missing modules are listed in security review remain open. Missing modules are listed in
+105
View File
@@ -0,0 +1,105 @@
// Browser automation is a reference-measurement tool, never an application renderer.
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
const [playwrightModule, destination] = process.argv.slice(2);
const require = createRequire(import.meta.url);
const { chromium } = require(playwrightModule);
const origin = process.env.YUVOMI_TEST_URL;
if (!/^https:\/\/127\.0\.0\.1:\d+$/.test(origin)) throw new Error('Only the temporary loopback fixture is allowed');
const browser = await chromium.launch({ headless: true, args: process.getuid?.() === 0 ? ['--no-sandbox'] : [] });
const width = Number(process.env.YUVOMI_VISUAL_WIDTH || 360);
const height = Number(process.env.YUVOMI_VISUAL_HEIGHT || 640);
const measurements = {
upstreamRevision: '10bfef7686ed7303b4c74c7fcc55a5eb16356c9d', browser: browser.version(),
androidFontConfiguration: Boolean(process.env.FONTCONFIG_FILE),
fixture: { widgets: ['tasks', 'calendar', 'shopping', 'notes'], mobileFavorites: ['tasks', 'kitchen', 'notes'] }, screens: [],
};
try {
const context = await browser.newContext({
viewport: { width, height }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, reducedMotion: 'reduce',
locale: 'de-DE', timezoneId: 'Europe/Berlin', ignoreHTTPSErrors: true,
userAgent: 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36',
serviceWorkers: 'block',
});
await context.route('**/*', route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort());
const api = context.request;
const password = process.env.YUVOMI_TEST_PASSWORD;
let csrf = '';
async function request(route, data) {
const response = await api.fetch(origin + '/api/v1' + route, {
method: data === undefined ? 'GET' : (route === '/preferences' ? 'PUT' : 'POST'),
data, headers: { 'X-CSRF-Token': csrf },
});
if (!response.ok()) throw new Error(`Fixture API failed at ${route}: HTTP ${response.status()}`);
const result = await response.json();
if (result.csrfToken) csrf = result.csrfToken;
return result;
}
await request('/auth/setup', { username: 'mobiletest', display_name: 'Mobile Test', password });
await request('/auth/login', { username: 'mobiletest', password });
await request('/auth/onboarding-seen', {});
await request('/auth/changelog-seen', {});
const today = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date());
await request('/tasks', { title: 'Gemeinsam den Alltag planen', priority: 'high', due_date: today });
await request('/notes', { title: 'Willkommen zuhause', content: 'Native Oberfläche, eigener Server.', pinned: 1 });
const list = await request('/shopping', { name: 'Wochenmarkt' });
await request(`/shopping/${list.data.id}/items`, { name: 'Äpfel', quantity: '4' });
// Match the implemented native slice, not a fabricated full-module acceptance.
await request('/preferences', {
dashboard_widgets: ['tasks', 'calendar', 'shopping', 'notes'].map((id, order) => ({ id, visible: true, order, size: '1x1' })),
dashboard_today_glance: false, mobile_nav_order: ['tasks', 'kitchen', 'notes'],
timezone: 'Europe/Berlin', timezone_hint_dismissed: true,
});
await request('/auth/logout', {});
const page = await context.newPage();
await page.addInitScript(() => {
localStorage.setItem('yuvomi-language', 'de');
localStorage.setItem('yuvomi-lang', 'de');
});
const selectors = ['.auth-page', '.auth-hero', '.auth-hero__mark', '.auth-hero__title', '.auth-hero__tagline', '.label', '.input', '.btn--primary', '.dashboard-overview__date',
'.dashboard-overview__title', '.widget', '.widget__header', '.widget__title', '.widget__body', '.nav-bottom__items',
'.nav-bottom .nav-item', '.page-toolbar', '.page-toolbar__title', '.task-card', '.task-card__title', '.note-card', '.list-row'];
async function capture(name) {
await page.evaluate(() => document.fonts.ready);
// Compare neutral states, not a keyboard focus ring left by fixture navigation.
await page.evaluate(() => document.activeElement?.blur?.());
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await page.screenshot({ path: path.join(destination, `${name}.png`), animations: 'disabled' });
measurements.screens.push({ name, viewport: page.viewportSize(), elements: await page.evaluate(selectors =>
selectors.flatMap(selector => [...document.querySelectorAll(selector)].slice(0, 5).map(element => {
const bounds = element.getBoundingClientRect(); const style = getComputedStyle(element);
return { selector, text: element.matches('input') ? '' : element.textContent.trim().slice(0, 80),
x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height,
font: style.fontFamily, fontSize: style.fontSize, fontWeight: style.fontWeight, lineHeight: style.lineHeight,
letterSpacing: style.letterSpacing, color: style.color, background: style.backgroundColor,
radius: style.borderRadius, padding: style.padding };
})), selectors) });
}
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce' });
await page.goto(origin + '/login');
await page.evaluate(value => localStorage.setItem('yuvomi-theme', value), theme);
await page.reload();
await page.locator('#auth-btn').waitFor();
await capture(`login-${theme}-${width}`);
await page.locator('#username').fill('mobiletest');
await page.locator('#password').fill(password);
await page.locator('#auth-btn').click();
await page.locator('.dashboard-overview__title').waitFor();
await page.locator('.widget--tasks .widget__body').waitFor();
await capture(`dashboard-${theme}-${width}`);
for (const [route, selector, name] of [['/tasks', '.task-card', 'tasks'], ['/notes', '.note-card', 'notes'], ['/shopping', '.shopping-item', 'shopping']]) {
await page.goto(origin + route);
await page.locator(selector).first().waitFor();
await capture(`${name}-${theme}-${width}`);
}
await request('/auth/me');
await request('/auth/logout', {});
}
await context.close();
} finally {
fs.writeFileSync(path.join(destination, 'measurements.json'), JSON.stringify(measurements, null, 2) + '\n');
await browser.close();
}
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Capture the unmodified pinned PWA with synthetic data, never a user's server."""
import argparse
import json
import os
from pathlib import Path
import secrets
import subprocess
import tempfile
import time
ROOT = Path(__file__).resolve().parents[1]
REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d"
def run(upstream, playwright_module, destination, width=360, height=640):
revision = subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip()
assert revision == REVISION, "Visual reference must use pinned unmodified upstream"
assert not subprocess.check_output(["git", "-C", str(upstream), "diff", "HEAD", "--name-only"], text=True).strip()
destination.mkdir(parents=True, exist_ok=True)
base = "/tmp/opencode" if Path("/tmp/opencode").exists() else None
with tempfile.TemporaryDirectory(prefix="yuvomi-visual-", dir=base) as temporary:
directory = Path(temporary)
subprocess.run([
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
"-keyout", str(directory / "key.pem"), "-out", str(directory / "cert.pem"),
"-subj", "/CN=Yuvomi visual fixture",
"-addext", "subjectAltName=IP:127.0.0.1,DNS:localhost",
], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
env = {**os.environ, "NODE_EXTRA_CA_CERTS": str(directory / "cert.pem"),
"YUVOMI_TEST_DISABLE_OIDC": "true", "YUVOMI_TEST_PASSWORD": secrets.token_urlsafe(24),
"YUVOMI_VISUAL_WIDTH": str(width), "YUVOMI_VISUAL_HEIGHT": str(height)}
with (directory / "server.log").open("w") as log:
server = subprocess.Popen(["node", str(ROOT / "scripts/integration-server.mjs"), str(upstream), temporary],
cwd=upstream, env=env, stdout=log, stderr=log)
try:
for _ in range(200):
if (directory / "ready.json").exists():
break
if server.poll() is not None:
raise RuntimeError("Visual fixture failed to start; no private logs exported")
time.sleep(0.1)
ready = json.loads((directory / "ready.json").read_text())
env["YUVOMI_TEST_URL"] = f"https://127.0.0.1:{ready['port']}"
subprocess.run(["node", str(ROOT / "scripts/capture-pwa-reference.mjs"),
str(playwright_module), str(destination)], env=env, check=True)
finally:
server.terminate()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
server.wait()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("upstream", type=Path)
parser.add_argument("--playwright-module", type=Path, required=True)
parser.add_argument("--output", type=Path, default=ROOT / "artifacts/visual/pwa")
parser.add_argument("--width", type=int, default=360)
parser.add_argument("--height", type=int, default=640)
args = parser.parse_args()
assert 320 <= args.width <= 1600 and 320 <= args.height <= 1800, "Invalid reference viewport"
run(args.upstream.resolve(), args.playwright_module.resolve(), args.output.resolve(), args.width, args.height)
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Bounded Apple toolchain probes; never install components or change the host."""
import subprocess
import sys
def probe(command, timeout=60):
label = " ".join(command)
print(f"Runner prerequisite: checking {label} (timeout {timeout}s)", flush=True)
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False)
except subprocess.TimeoutExpired as error:
raise RuntimeError(
f"{label} did not return within {timeout}s. Check selected Xcode, first-launch components "
"and CoreSimulator in the runner user's interactive login session; run this command there. "
"CI has not accepted licenses or changed host settings."
) from error
except OSError as error:
raise RuntimeError(f"Cannot start {label}: {error}") from error
if result.returncode:
raise RuntimeError(f"{label} exited {result.returncode}: {(result.stderr or result.stdout).strip()}")
return result.stdout
def main():
try:
print(probe(["xcodebuild", "-version"]).strip(), flush=True)
probe(["xcrun", "--find", "swift"])
probe(["xcrun", "simctl", "list", "devices", "available"])
except RuntimeError as error:
print(f"Runner prerequisite: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -3
View File
@@ -17,9 +17,7 @@ case "${1:-}" in
ios) ios)
[[ "$(uname -s)" == Darwin && "$(uname -m)" == arm64 ]] || fail 'macos-arm64 must execute natively on the Apple Silicon host' [[ "$(uname -s)" == Darwin && "$(uname -m)" == arm64 ]] || fail 'macos-arm64 must execute natively on the Apple Silicon host'
command -v xcodebuild >/dev/null || fail 'install full Xcode and select it with xcode-select' command -v xcodebuild >/dev/null || fail 'install full Xcode and select it with xcode-select'
xcodebuild -version python3 "${BASH_SOURCE[0]%/*}/check-apple-tools.py"
xcrun --find swift >/dev/null
xcrun simctl list devices available >/dev/null
;; ;;
*) fail 'usage: bash scripts/check-ci-runner.sh android|emulator|ios' ;; *) fail 'usage: bash scripts/check-ci-runner.sh android|emulator|ios' ;;
esac esac
+53
View File
@@ -0,0 +1,53 @@
// Development-only side-by-side evidence. This deliberately asserts no pixel parity.
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { createHash } from 'node:crypto';
const [modulePath, nativeDirectory, pwaDirectory, destination] = process.argv.slice(2);
if (!destination) throw new Error('Usage: node compare-native-pwa.mjs PLAYWRIGHT_MODULE NATIVE_DIR PWA_DIR OUTPUT_DIR');
const { chromium } = createRequire(import.meta.url)(path.resolve(modulePath));
const viewport = JSON.parse(fs.readFileSync(path.join(nativeDirectory, 'viewport.json')));
if (viewport.fontScale !== 1) throw new Error('This CSS reference comparison requires fontScale 1');
const cssWidth = viewport.widthPx / viewport.density;
const cssHeight = viewport.heightPx / viewport.density;
const reference = JSON.parse(fs.readFileSync(path.join(pwaDirectory, 'measurements.json')));
if (!reference.screens.every(screen => screen.viewport.width === cssWidth && screen.viewport.height === cssHeight)) {
throw new Error('PWA and native content viewports do not match');
}
function image(directory, name) {
const bytes = fs.readFileSync(path.join(directory, name));
if (bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' ||
bytes.readUInt32BE(16) !== viewport.widthPx || bytes.readUInt32BE(20) !== viewport.heightPx) {
throw new Error(`Invalid or mismatched PNG: ${name}`);
}
return { uri: `data:image/png;base64,${bytes.toString('base64')}`, sha256: createHash('sha256').update(bytes).digest('hex') };
}
fs.mkdirSync(destination, { recursive: true });
const browser = await chromium.launch({ headless: true, args: process.getuid?.() === 0 ? ['--no-sandbox'] : [] });
const records = [];
try {
const page = await browser.newPage({ viewport: { width: viewport.widthPx * 2, height: viewport.heightPx + 48 }, deviceScaleFactor: 1 });
await page.route('**/*', route => route.abort());
for (const name of ['login', 'dashboard', 'tasks', 'notes', 'shopping']) {
for (const theme of ['light', 'dark']) {
const native = image(nativeDirectory, `${name}-${theme}.png`);
const pwa = image(pwaDirectory, `${name}-${theme}-${cssWidth}.png`);
await page.setContent(`<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'">
<style>body{margin:0;background:#eee;font:20px sans-serif}main{display:flex}section{width:${viewport.widthPx}px}
header{height:48px;display:grid;place-items:center}img{display:block;width:100%}</style>
<main><section><header>PWA 2.78.0 · ${name} · ${theme}</header><img src="${pwa.uri}"></section>
<section><header>Android nativ · ${name} · ${theme} · keine 1:1-Abnahme</header><img src="${native.uri}"></section></main>`);
await page.locator('img').evaluateAll(images => Promise.all(images.map(image => image.decode())));
const file = `${name}-${theme}-comparison.png`;
await page.screenshot({ path: path.join(destination, file) });
records.push({ file, nativeSha256: native.sha256, pwaSha256: pwa.sha256 });
}
}
} finally {
await browser.close();
}
fs.writeFileSync(path.join(destination, 'comparison.json'), JSON.stringify({
status: 'Side-by-side evidence only; no visual acceptance', viewport, upstreamRevision: reference.upstreamRevision, records,
}, null, 2) + '\n');
console.log(`Wrote ${records.length} matched-viewport comparisons; no parity assertion.`);
+20 -9
View File
@@ -13,15 +13,24 @@ process.env.PORT = '0';
process.env.BIND_ADDRESS = '127.0.0.1'; process.env.BIND_ADDRESS = '127.0.0.1';
process.env.DB_PATH = path.join(directory, 'fixture.db'); process.env.DB_PATH = path.join(directory, 'fixture.db');
process.env.SESSION_SECRET = randomBytes(48).toString('hex'); process.env.SESSION_SECRET = randomBytes(48).toString('hex');
process.env.SESSION_SECURE = 'true'; // Exercise both explicit Secure cookies and upstream's default behind HTTPS.
const sessionSecure = process.env.YUVOMI_TEST_SESSION_SECURE ?? 'true';
if (!['true', 'false'].includes(sessionSecure)) throw new Error('Invalid fixture cookie mode');
process.env.SESSION_SECURE = sessionSecure;
process.env.DB_ENCRYPTION_KEY = ''; process.env.DB_ENCRYPTION_KEY = '';
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
process.env.LOG_LEVEL = 'warn'; process.env.LOG_LEVEL = 'warn';
const tls = { key: fs.readFileSync(path.join(directory, 'key.pem')), cert: fs.readFileSync(path.join(directory, 'cert.pem')) }; const tls = { key: fs.readFileSync(path.join(directory, 'key.pem')), cert: fs.readFileSync(path.join(directory, 'cert.pem')) };
const idp = await startIdentityProvider(tls); const idp = process.env.YUVOMI_TEST_DISABLE_OIDC === 'true' ? null : await startIdentityProvider(tls);
process.env.OIDC_ISSUER = idp.issuer; if (idp) {
process.env.OIDC_CLIENT_ID = idp.clientId; process.env.OIDC_ISSUER = idp.issuer;
process.env.OIDC_CLIENT_SECRET = idp.secret; process.env.OIDC_CLIENT_ID = idp.clientId;
process.env.OIDC_CLIENT_SECRET = idp.secret;
} else {
delete process.env.OIDC_ISSUER;
delete process.env.OIDC_CLIENT_ID;
delete process.env.OIDC_CLIENT_SECRET;
}
const { server } = await import(pathToFileURL(path.join(upstream, 'server/index.js'))); const { server } = await import(pathToFileURL(path.join(upstream, 'server/index.js')));
if (!server.listening) await once(server, 'listening'); if (!server.listening) await once(server, 'listening');
let networkAvailable = true; let networkAvailable = true;
@@ -47,7 +56,9 @@ const proxy = https.createServer(tls, (request, response) => {
}); });
proxy.listen(0, '127.0.0.1'); proxy.listen(0, '127.0.0.1');
await once(proxy, 'listening'); await once(proxy, 'listening');
process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`; if (idp) {
idp.setRedirect(process.env.OIDC_REDIRECT_URI); process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`;
fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp.port })); idp.setRedirect(process.env.OIDC_REDIRECT_URI);
process.on('SIGTERM', () => { idp.close(); proxy.close(); server.close(); process.exit(0); }); }
fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp?.port ?? null }));
process.on('SIGTERM', () => { idp?.close(); proxy.close(); server.close(); process.exit(0); });
+30 -4
View File
@@ -1,24 +1,32 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Test runner prerequisite decisions with fake tools, not actual Apple SDKs.""" """Test runner prerequisite decisions with fake tools, not actual Apple SDKs."""
import os import os
import importlib.util
from pathlib import Path from pathlib import Path
import shlex
import subprocess import subprocess
import sys
import tempfile import tempfile
import unittest import unittest
SCRIPT = Path(__file__).with_name("check-ci-runner.sh") SCRIPT = Path(__file__).with_name("check-ci-runner.sh")
SPEC = importlib.util.spec_from_file_location("apple_tools", SCRIPT.with_name("check-apple-tools.py"))
APPLE_TOOLS = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(APPLE_TOOLS)
class RunnerPrerequisiteTests(unittest.TestCase): class RunnerPrerequisiteTests(unittest.TestCase):
def run_check(self, mode, system="Linux", architecture="x86_64", missing=None, broken_xcode=False): def run_check(self, mode, system="Linux", architecture="x86_64", missing=None, broken_xcode=False, broken_xcrun=False):
with tempfile.TemporaryDirectory(prefix="yuvomi-runner-test-") as temporary: base = "/tmp/opencode" if Path("/tmp/opencode").exists() else None
with tempfile.TemporaryDirectory(prefix="yuvomi-runner-test-", dir=base) as temporary:
directory = Path(temporary) directory = Path(temporary)
for name in ["git", "curl", "unzip", "python3", "xcodebuild", "xcrun"]: for name in ["git", "curl", "unzip", "python3", "xcodebuild", "xcrun"]:
if name == missing: if name == missing:
continue continue
path = directory / name path = directory / name
status = 1 if name == "xcodebuild" and broken_xcode else 0 status = 1 if (name == "xcodebuild" and broken_xcode or name == "xcrun" and broken_xcrun) else 0
path.write_text(f"#!/bin/sh\nexit {status}\n") path.write_text(f"#!/bin/sh\nexec {shlex.quote(sys.executable)} \"$@\"\n" if name == "python3"
else f"#!/bin/sh\nexit {status}\n")
path.chmod(0o700) path.chmod(0o700)
uname = directory / "uname" uname = directory / "uname"
uname.write_text('#!/bin/sh\ncase "$1" in -s) echo "$TEST_SYSTEM";; -m) echo "$TEST_ARCH";; esac\n') uname.write_text('#!/bin/sh\ncase "$1" in -s) echo "$TEST_SYSTEM";; -m) echo "$TEST_ARCH";; esac\n')
@@ -62,6 +70,24 @@ class RunnerPrerequisiteTests(unittest.TestCase):
def test_unknown_mode_fails(self): def test_unknown_mode_fails(self):
self.assertNotEqual(self.run_check("other").returncode, 0) self.assertNotEqual(self.run_check("other").returncode, 0)
def test_missing_xcrun_is_actionable(self):
result = self.run_check("ios", system="Darwin", architecture="arm64", missing="xcrun")
self.assertNotEqual(result.returncode, 0)
self.assertIn("Cannot start xcrun", result.stderr)
def test_broken_xcrun_is_not_a_pass(self):
result = self.run_check("ios", system="Darwin", architecture="arm64", broken_xcrun=True)
self.assertNotEqual(result.returncode, 0)
self.assertIn("xcrun --find swift exited 1", result.stderr)
def test_unresponsive_apple_probe_times_out_with_guidance(self):
with self.assertRaisesRegex(RuntimeError, "interactive login session"):
APPLE_TOOLS.probe([sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.05)
def test_apple_probe_preserves_tool_failure_diagnostic(self):
with self.assertRaisesRegex(RuntimeError, "fixture tool failure"):
APPLE_TOOLS.probe([sys.executable, "-c", "import sys; sys.exit('fixture tool failure')"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+37
View File
@@ -2,6 +2,7 @@
"""Regressions for AGP screenshot output collection, not visual parity tests.""" """Regressions for AGP screenshot output collection, not visual parity tests."""
import base64 import base64
import importlib.util import importlib.util
import json
from pathlib import Path from pathlib import Path
import tempfile import tempfile
import unittest import unittest
@@ -57,6 +58,42 @@ class ScreenshotEvidenceTests(unittest.TestCase):
self.assertFalse((self.screenshots / image.name).exists()) self.assertFalse((self.screenshots / image.name).exists())
self.assertEqual(unrelated.read_text(), "preserve") self.assertEqual(unrelated.read_text(), "preserve")
def gallery(self):
parent = self.fixture().parent
for name in HARNESS.SCREENSHOTS:
(parent / name).write_bytes(PNG)
(parent / "viewport.json").write_text(json.dumps({"widthPx": 720, "heightPx": 1184, "density": 2}))
return parent
def test_collects_complete_allowlisted_gallery(self):
parent = self.gallery()
(parent / "private.txt").write_text("not exported")
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
self.assertEqual({p.name for p in self.screenshots.iterdir()}, set(HARNESS.VISUAL_OUTPUTS))
def test_partial_gallery_is_not_exported(self):
parent = self.gallery()
(parent / "notes-dark.png").unlink()
with self.assertRaisesRegex(AssertionError, "Missing or ambiguous"):
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
self.assertFalse(self.screenshots.exists())
def test_clears_every_known_gallery_file_but_preserves_unrelated(self):
parent = self.gallery()
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
(parent / "other.png").write_bytes(PNG)
HARNESS.prepare_android_screenshots(self.output)
self.assertEqual([p.name for p in parent.iterdir()], ["other.png"])
self.assertEqual(list(self.screenshots.iterdir()), [])
def test_rejects_gallery_split_across_devices(self):
parent = self.gallery()
other = parent.with_name("second")
other.mkdir()
(parent / "notes-dark.png").rename(other / "notes-dark.png")
with self.assertRaisesRegex(AssertionError, "Mixed-device"):
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+36 -16
View File
@@ -20,28 +20,44 @@ import urllib.request
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d" REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d"
SCREENSHOTS = ("native-dashboard.png",) + tuple(
f"{page}-{theme}.png" for page in ("login", "dashboard", "tasks", "notes", "shopping") for theme in ("light", "dark"))
VISUAL_OUTPUTS = SCREENSHOTS + ("viewport.json",)
def prepare_android_screenshots(app_output): def prepare_android_screenshots(app_output):
additional = app_output / "connected_android_test_additional_output/integrationAndroidTest" additional = app_output / "connected_android_test_additional_output/integrationAndroidTest"
screenshots = app_output / "native-screenshots" screenshots = app_output / "native-screenshots"
# Never accept a previous run's screenshot as current evidence. # Never accept a previous run's screenshot as current evidence.
for stale in additional.rglob("native-dashboard.png"): for name in VISUAL_OUTPUTS:
stale.unlink() for stale in additional.rglob(name):
(screenshots / "native-dashboard.png").unlink(missing_ok=True) stale.unlink()
(screenshots / name).unlink(missing_ok=True)
return additional, screenshots return additional, screenshots
def collect_native_screenshot(additional, screenshots): def collect_native_screenshot(additional, screenshots, gallery=False):
captures = list(additional.rglob("native-dashboard.png")) names = VISUAL_OUTPUTS if gallery else ("native-dashboard.png",)
assert len(captures) == 1, "Missing or ambiguous native fixture screenshot" validated = {}
capture = captures[0].read_bytes() parents = set()
assert capture.startswith(b"\x89PNG\r\n\x1a\n"), "Invalid native fixture screenshot" for name in names:
captures = list(additional.rglob(name))
assert len(captures) == 1, f"Missing or ambiguous native fixture screenshot: {name}"
capture = captures[0].read_bytes()
if name.endswith(".png"):
assert capture.startswith(b"\x89PNG\r\n\x1a\n"), "Invalid native fixture screenshot"
else:
viewport = json.loads(capture)
assert viewport["widthPx"] > 0 and viewport["heightPx"] > 0 and viewport["density"] > 0
parents.add(captures[0].parent)
validated[name] = capture
assert len(parents) == 1, "Mixed-device native gallery evidence"
screenshots.mkdir(parents=True, exist_ok=True) screenshots.mkdir(parents=True, exist_ok=True)
(screenshots / "native-dashboard.png").write_bytes(capture) for name, capture in validated.items():
(screenshots / name).write_bytes(capture)
def run(upstream, android): def run(upstream, android, cookie_without_secure=False):
revision = subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip() revision = subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip()
assert revision == REVISION, f"Expected pinned upstream {REVISION}, got {revision}" assert revision == REVISION, f"Expected pinned upstream {REVISION}, got {revision}"
base_temp = Path("/tmp/opencode") if Path("/tmp/opencode").exists() else Path(tempfile.gettempdir()) base_temp = Path("/tmp/opencode") if Path("/tmp/opencode").exists() else Path(tempfile.gettempdir())
@@ -56,7 +72,8 @@ def run(upstream, android):
with (directory / "server.log").open("w") as log: with (directory / "server.log").open("w") as log:
process = subprocess.Popen(["node", str(ROOT / "scripts/integration-server.mjs"), str(upstream), temporary], process = subprocess.Popen(["node", str(ROOT / "scripts/integration-server.mjs"), str(upstream), temporary],
cwd=upstream, stdout=log, stderr=log, cwd=upstream, stdout=log, stderr=log,
env={**os.environ, "NODE_EXTRA_CA_CERTS": str(directory / "cert.pem")}) env={**os.environ, "NODE_EXTRA_CA_CERTS": str(directory / "cert.pem"),
"YUVOMI_TEST_SESSION_SECURE": "false" if cookie_without_secure else "true"})
try: try:
for _ in range(200): for _ in range(200):
if (directory / "ready.json").exists(): if (directory / "ready.json").exists():
@@ -102,7 +119,7 @@ def run(upstream, android):
status, body, headers = request("/api/v1/auth/login", {"username": "mobiletest", "password": password}) status, body, headers = request("/api/v1/auth/login", {"username": "mobiletest", "password": password})
assert status == 200 assert status == 200
session = [cookie for cookie in cookies if cookie.name == "yuvomi.sid"][0] session = [cookie for cookie in cookies if cookie.name == "yuvomi.sid"][0]
assert session.secure and session.has_nonstandard_attr("HttpOnly") assert session.secure == (not cookie_without_secure) and session.has_nonstandard_attr("HttpOnly")
assert "SameSite=Lax" in " ".join(headers.get_all("Set-Cookie")) assert "SameSite=Lax" in " ".join(headers.get_all("Set-Cookie"))
csrf = json.loads(body)["csrfToken"] csrf = json.loads(body)["csrfToken"]
assert request("/api/v1/auth/me")[0] == 200 assert request("/api/v1/auth/me")[0] == 200
@@ -110,7 +127,8 @@ def run(upstream, android):
assert request("/api/v1/auth/logout", {})[0] == 403 assert request("/api/v1/auth/logout", {})[0] == 403
assert request("/api/v1/auth/logout", {}, {"X-CSRF-Token": csrf})[0] == 200 assert request("/api/v1/auth/logout", {}, {"X-CSRF-Token": csrf})[0] == 200
assert request("/api/v1/auth/me")[0] == 401 assert request("/api/v1/auth/me")[0] == 401
print("PASS: login, Secure/HttpOnly/SameSite cookies, persistence, CSRF rejection and logout/revocation") cookie_mode = "HTTPS with upstream-default non-Secure session cookie" if cookie_without_secure else "Secure session cookie"
print(f"PASS: login, {cookie_mode}, HttpOnly/SameSite, persistence, CSRF rejection and logout/revocation")
assert request("/api/v1/auth/oidc/start")[0] == 200 assert request("/api/v1/auth/oidc/start")[0] == 200
status, body, _ = request("/api/v1/auth/me") status, body, _ = request("/api/v1/auth/me")
identity = json.loads(body) identity = json.loads(body)
@@ -150,8 +168,8 @@ def run(upstream, android):
raise RuntimeError("Android integration tests failed. Inspect the Gradle test report.") raise RuntimeError("Android integration tests failed. Inspect the Gradle test report.")
reports = app_output / "androidTest-results/connected/integration" reports = app_output / "androidTest-results/connected/integration"
subprocess.run(["python3", str(ROOT / "scripts/verify-test-results.py"), subprocess.run(["python3", str(ROOT / "scripts/verify-test-results.py"),
str(reports), "--minimum", "7"], check=True) str(reports), "--minimum", "10"], check=True)
collect_native_screenshot(additional, screenshots) collect_native_screenshot(additional, screenshots, gallery=True)
finally: finally:
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{port}"], check=False) subprocess.run([str(adb), "reverse", "--remove", f"tcp:{port}"], check=False)
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{ready['idpPort']}"], check=False) subprocess.run([str(adb), "reverse", "--remove", f"tcp:{ready['idpPort']}"], check=False)
@@ -168,5 +186,7 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("upstream", type=Path) parser.add_argument("upstream", type=Path)
parser.add_argument("--android", action="store_true") parser.add_argument("--android", action="store_true")
parser.add_argument("--cookie-without-secure", action="store_true",
help="Test upstream's default session-cookie attributes over HTTPS; never enables HTTP")
arguments = parser.parse_args() arguments = parser.parse_args()
run(arguments.upstream.resolve(), arguments.android) run(arguments.upstream.resolve(), arguments.android, arguments.cookie_without_secure)