SHA256
Compare commits
6
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
e0938df256 | ||
|
|
7ac9882307 | ||
|
|
bd0e4cc4fb | ||
|
|
4b483c20e3 | ||
|
|
129eac675c | ||
|
|
92dbd0e06d |
No files matched your search
@@ -98,8 +98,9 @@ jobs:
|
||||
disable-animations: true
|
||||
script: |
|
||||
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 --cookie-without-secure
|
||||
- uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de
|
||||
if: always()
|
||||
with:
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
|
||||
## [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,
|
||||
protected native session storage and initial auth/dashboard/task/note/shopping
|
||||
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,
|
||||
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,
|
||||
recovery, server erasure, camera/document selection, downloads and blob exports.
|
||||
- SwiftUI/WKWebView iOS implementation, isolated web sign-in, native settings,
|
||||
|
||||
@@ -21,6 +21,7 @@ import a browser/WebView session; sign in again after upgrading the prototype.
|
||||
|
||||
- [Architecture](docs/architecture.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)
|
||||
- [Compatibility and authentication](docs/server-compatibility.md)
|
||||
- [Release process](docs/release-process.md)
|
||||
|
||||
+5
-2
@@ -16,8 +16,11 @@ server vulnerabilities belong in its private reporting channel.
|
||||
- Passwords and second-factor codes exist transiently for the API request; they
|
||||
are not saved to preferences, keychains, logs, artifacts or application state
|
||||
restoration. Server cookies, not passwords, persist the session.
|
||||
- Android persists allow-listed Secure cookies with their absolute expiry in
|
||||
AES-GCM ciphertext backed by AndroidKeyStore, with the origin authenticated as
|
||||
- Android treats every allow-listed native cookie as HTTPS-only, bound to the
|
||||
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
|
||||
Keychain items protected with WhenUnlockedThisDeviceOnly.
|
||||
- CSRF tokens follow the upstream API protocol. Only an explicit `csrf_invalid`
|
||||
|
||||
@@ -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
|
||||
colors (`public/utils/brand-mark.js` and `docs/logo.svg`); its MIT attribution is
|
||||
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
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ android {
|
||||
applicationId = "community.yuvomi.mobile"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 2
|
||||
versionName = "0.2.0-native-preview"
|
||||
versionCode = 4
|
||||
versionName = "0.2.2-native-preview"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
+118
-14
@@ -1,7 +1,10 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
@@ -11,7 +14,6 @@ import androidx.compose.ui.test.isEnabled
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
@@ -33,6 +35,9 @@ class NativeUpstreamTest {
|
||||
val reset = object : ExternalResource() {
|
||||
override fun before() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
context.getSystemService(UiModeManager::class.java).setApplicationNightMode(UiModeManager.MODE_NIGHT_NO)
|
||||
}
|
||||
context.getSharedPreferences("native-server", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
context.getSharedPreferences("native-session", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
if (android.os.Build.VERSION.SDK_INT >= 37) {
|
||||
@@ -72,23 +77,59 @@ class NativeUpstreamTest {
|
||||
org.json.JSONObject().put("name", "Wochenmarkt"),
|
||||
).getJSONObject("data").getLong("id")
|
||||
seed.request("/shopping/$seedList/items", "POST", org.json.JSONObject().put("name", "Äpfel").put("quantity", "4"))
|
||||
seed.request(
|
||||
"/preferences",
|
||||
"PUT",
|
||||
org.json.JSONObject()
|
||||
.put(
|
||||
"dashboard_widgets",
|
||||
org.json.JSONArray(
|
||||
listOf("tasks", "calendar", "shopping", "notes").mapIndexed { order, id ->
|
||||
org.json.JSONObject().put("id", id).put("visible", true).put("order", order).put("size", "1x1")
|
||||
},
|
||||
),
|
||||
)
|
||||
.put("mobile_nav_order", org.json.JSONArray(listOf("tasks", "kitchen", "notes")))
|
||||
.put("timezone", "Europe/Berlin"),
|
||||
)
|
||||
seed.request("/auth/logout", "POST")
|
||||
compose.onNodeWithTag("server-url").performTextInput(origin)
|
||||
compose.onNodeWithTag("connect").performClick()
|
||||
awaitTag("username")
|
||||
captureFixtureScreen("login-light.png")
|
||||
nightMode(true)
|
||||
awaitTag("username")
|
||||
captureFixtureScreen("login-dark.png")
|
||||
nightMode(false)
|
||||
awaitTag("username")
|
||||
compose.onNodeWithTag("username").performTextInput("mobiletest")
|
||||
compose.onNodeWithTag("password").performTextInput(password + "-invalid")
|
||||
compose.onNodeWithTag("login").performScrollTo().performClick()
|
||||
awaitTag("auth-error")
|
||||
compose.onNodeWithText("Benutzername oder Passwort wurden vom Server nicht akzeptiert.").assertIsDisplayed()
|
||||
compose.onNodeWithTag("password").performTextInput(password)
|
||||
compose.onNodeWithTag("login").performScrollTo().performClick()
|
||||
awaitTag("native-dashboard")
|
||||
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.RESUMED)
|
||||
compose.activityRule.scenario.recreate()
|
||||
awaitTag("native-dashboard")
|
||||
|
||||
compose.onNodeWithTag("nav-TASKS").performClick()
|
||||
awaitText("Hinzufügen")
|
||||
compose.onNodeWithText("Hinzufügen").performClick()
|
||||
awaitTag("add-entry")
|
||||
compose.onNodeWithTag("add-entry").performClick()
|
||||
compose.onNodeWithTag("entry-title").performTextInput("Native task regression")
|
||||
compose.onNodeWithTag("entry-detail").performTextInput("Created with native controls")
|
||||
compose.onNodeWithTag("entry-save").performClick()
|
||||
@@ -96,8 +137,8 @@ class NativeUpstreamTest {
|
||||
assertTrue(readApi(origin).request("/tasks").rows().any { it.text("title") == "Native task regression" })
|
||||
|
||||
compose.onNodeWithTag("nav-NOTES").performClick()
|
||||
awaitText("Hinzufügen")
|
||||
compose.onNodeWithText("Hinzufügen").performClick()
|
||||
awaitTag("add-entry")
|
||||
compose.onNodeWithTag("add-entry").performClick()
|
||||
compose.onNodeWithTag("entry-title").performTextInput("Native note regression")
|
||||
compose.onNodeWithTag("entry-detail").performTextInput("Not an embedded web page")
|
||||
compose.onNodeWithTag("entry-save").performClick()
|
||||
@@ -105,14 +146,14 @@ class NativeUpstreamTest {
|
||||
assertTrue(readApi(origin).request("/notes").rows().any { it.text("content") == "Not an embedded web page" })
|
||||
|
||||
compose.onNodeWithTag("nav-SHOPPING").performClick()
|
||||
awaitText("Neue Liste")
|
||||
compose.onNodeWithText("Neue Liste").performClick()
|
||||
awaitTag("add-list")
|
||||
compose.onNodeWithTag("add-list").performClick()
|
||||
compose.onNodeWithTag("entry-title").performTextInput("Native shopping regression")
|
||||
compose.onNodeWithTag("entry-save").performClick()
|
||||
awaitText("Native shopping regression")
|
||||
compose.onNodeWithText("Native shopping regression").performClick()
|
||||
awaitText("Hinzufügen")
|
||||
compose.onNodeWithText("Hinzufügen").performClick()
|
||||
awaitTag("add-entry")
|
||||
compose.onNodeWithTag("add-entry").performClick()
|
||||
compose.onNodeWithTag("entry-title").performTextInput("Native apples")
|
||||
compose.onNodeWithTag("entry-detail").performTextInput("3")
|
||||
compose.onNodeWithTag("entry-save").performClick()
|
||||
@@ -150,15 +191,67 @@ class NativeUpstreamTest {
|
||||
return api
|
||||
}
|
||||
|
||||
private fun captureFixtureDashboard() {
|
||||
private fun nightMode(dark: Boolean) {
|
||||
check(Build.VERSION.SDK_INT >= 31) { "The reference gallery requires per-app night mode (API 31+)" }
|
||||
compose.activityRule.scenario.onActivity {
|
||||
it.getSystemService(UiModeManager::class.java).setApplicationNightMode(
|
||||
if (dark) UiModeManager.MODE_NIGHT_YES else UiModeManager.MODE_NIGHT_NO,
|
||||
)
|
||||
}
|
||||
compose.waitUntil(10_000) {
|
||||
var matches = false
|
||||
compose.activityRule.scenario.onActivity {
|
||||
matches = (
|
||||
it.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
) == dark
|
||||
}
|
||||
matches
|
||||
}
|
||||
compose.waitForIdle()
|
||||
}
|
||||
|
||||
private fun captureModuleGallery(theme: String) {
|
||||
listOf(NativePage.TASKS, NativePage.NOTES, NativePage.SHOPPING).forEach { page ->
|
||||
compose.onNodeWithTag("nav-${page.name}").performClick()
|
||||
compose.waitUntil(30_000) {
|
||||
var ready = false
|
||||
compose.activityRule.scenario.onActivity {
|
||||
val state = ViewModelProvider(it)[NativeController::class.java].state
|
||||
ready = !state.busy && state.page == page && state.rows.isNotEmpty()
|
||||
}
|
||||
ready
|
||||
}
|
||||
captureFixtureScreen("${page.name.lowercase()}-$theme.png")
|
||||
}
|
||||
compose.onNodeWithTag("nav-HOME").performClick()
|
||||
awaitTag("native-dashboard")
|
||||
}
|
||||
|
||||
private fun captureFixtureScreen(name: String) {
|
||||
// Only this temporary-fixture test clears the screenshot restriction.
|
||||
// Ordinary builds always retain FLAG_SECURE, including native login.
|
||||
compose.activityRule.scenario.onActivity { it.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }
|
||||
compose.activityRule.scenario.onActivity {
|
||||
it.currentFocus?.clearFocus()
|
||||
it.window.insetsController?.hide(android.view.WindowInsets.Type.ime())
|
||||
it.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
try {
|
||||
compose.waitForIdle()
|
||||
compose.waitUntil(10_000) {
|
||||
var hidden = false
|
||||
compose.activityRule.scenario.onActivity {
|
||||
hidden = it.window.decorView.rootWindowInsets?.isVisible(android.view.WindowInsets.Type.ime()) != true
|
||||
}
|
||||
hidden
|
||||
}
|
||||
// PixelCopy must run after the real window/inset animation, not only Compose idleness.
|
||||
SystemClock.sleep(600)
|
||||
// Capture the native window through PixelCopy, not a potentially stale
|
||||
// secure compositor frame returned by UiAutomation immediately after clearing.
|
||||
val image = compose.onRoot().captureToImage().asAndroidBitmap()
|
||||
val node = compose.onNodeWithTag("native-screen")
|
||||
val bounds = node.fetchSemanticsNode().boundsInRoot
|
||||
val image = node.captureToImage().asAndroidBitmap()
|
||||
try {
|
||||
val first = image.getPixel(0, 0)
|
||||
assertTrue(
|
||||
@@ -168,9 +261,20 @@ class NativeUpstreamTest {
|
||||
},
|
||||
)
|
||||
// AGP collects this test output before uninstalling the fixture APK.
|
||||
PlatformTestStorageRegistry.getInstance().openOutputFile("native-dashboard.png").use {
|
||||
PlatformTestStorageRegistry.getInstance().openOutputFile(name).use {
|
||||
check(image.compress(Bitmap.CompressFormat.PNG, 100, it))
|
||||
}
|
||||
if (name == "native-dashboard.png") {
|
||||
val resources = compose.activity.resources
|
||||
val metadata = org.json.JSONObject()
|
||||
.put("widthPx", image.width).put("heightPx", image.height)
|
||||
.put("density", resources.displayMetrics.density).put("fontScale", resources.configuration.fontScale)
|
||||
.put("sdk", Build.VERSION.SDK_INT)
|
||||
.put("boundsInRoot", org.json.JSONArray(listOf(bounds.left, bounds.top, bounds.right, bounds.bottom)))
|
||||
PlatformTestStorageRegistry.getInstance().openOutputFile("viewport.json").use {
|
||||
it.write(metadata.toString(2).toByteArray())
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
image.recycle()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import org.json.JSONObject
|
||||
|
||||
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) {
|
||||
private val values = linkedMapOf<String, SessionCookie>()
|
||||
|
||||
@@ -35,7 +37,10 @@ class NativeCookies(private val server: ServerAddress, serialized: String? = nul
|
||||
for (header in headers) {
|
||||
val parsed = runCatching { HttpCookie.parse(header) }.getOrDefault(emptyList())
|
||||
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.path != null && cookie.path != "/") continue
|
||||
if (cookie.maxAge == 0L) {
|
||||
@@ -61,12 +66,27 @@ class NativeCookies(private val server: ServerAddress, serialized: String? = nul
|
||||
|
||||
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 safe(value: String) = value.length <= 8192 && value.none { it <= ' ' || it == ';' || it == ',' || it == '\u007f' }
|
||||
}
|
||||
|
||||
class NativeApiError(val status: Int, val reason: String = "") : Exception() {
|
||||
fun userMessage(): String = when (status) {
|
||||
class NativeApiError(val status: Int, val reason: String = "", val authenticationStep: String = "") : Exception() {
|
||||
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."
|
||||
403 -> "Diese Aktion ist nicht erlaubt. Bitte prüfe deine Berechtigungen."
|
||||
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()
|
||||
}
|
||||
|
||||
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 {
|
||||
require(path.startsWith("/") && !path.startsWith("//") && !path.contains('#') && !path.contains('\\'))
|
||||
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)
|
||||
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
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -31,6 +31,8 @@ data class NativeState(
|
||||
val resetAllowed: Boolean = false,
|
||||
val page: NativePage = NativePage.HOME,
|
||||
val dashboard: JSONObject = JSONObject(),
|
||||
val preferences: JSONObject = JSONObject(),
|
||||
val shoppingLists: List<JSONObject> = emptyList(),
|
||||
val rows: List<JSONObject> = emptyList(),
|
||||
val listId: Long? = null,
|
||||
val listName: String = "",
|
||||
@@ -66,7 +68,7 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
||||
val selectedGeneration = generation
|
||||
state = previous.copy(busy = true, error = null)
|
||||
worker.execute {
|
||||
val result = runCatching { block(previous) }
|
||||
val result = runCatching { block(previous.copy(error = null)) }
|
||||
main.post {
|
||||
if (generation != selectedGeneration) return@post
|
||||
state = result.fold(
|
||||
@@ -77,6 +79,8 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
||||
user = null,
|
||||
permissions = JSONObject(),
|
||||
dashboard = JSONObject(),
|
||||
preferences = JSONObject(),
|
||||
shoppingLists = emptyList(),
|
||||
rows = emptyList(),
|
||||
page = NativePage.HOME,
|
||||
listId = null,
|
||||
@@ -165,8 +169,15 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
|
||||
private fun authenticated(previous: NativeState, response: JSONObject): NativeState {
|
||||
requireNotNull(client).requireSessionCookie()
|
||||
val user = response.getJSONObject("user")
|
||||
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(
|
||||
user = user,
|
||||
permissions = permissions,
|
||||
@@ -174,12 +185,14 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
||||
twoFactor = false,
|
||||
page = NativePage.HOME,
|
||||
dashboard = JSONObject(),
|
||||
preferences = preferences,
|
||||
shoppingLists = emptyList(),
|
||||
rows = emptyList(),
|
||||
)
|
||||
return try {
|
||||
signedIn.copy(dashboard = requireNotNull(client).request("/dashboard"))
|
||||
} 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.")
|
||||
}
|
||||
}
|
||||
@@ -195,7 +208,16 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
||||
NativePage.HOME -> previous.copy(dashboard = api.request("/dashboard"))
|
||||
NativePage.TASKS -> previous.copy(rows = api.request("/tasks").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
|
||||
}
|
||||
}
|
||||
@@ -269,6 +291,21 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
||||
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 ->
|
||||
requireNotNull(client).request(
|
||||
"/shopping/items/${item.getLong("id")}",
|
||||
@@ -308,6 +345,8 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
||||
user = null,
|
||||
permissions = JSONObject(),
|
||||
dashboard = JSONObject(),
|
||||
preferences = JSONObject(),
|
||||
shoppingLists = emptyList(),
|
||||
rows = emptyList(),
|
||||
listId = null,
|
||||
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 {
|
||||
private val server = ServerAddress.parse("https://home.example:8443")
|
||||
|
||||
@Test fun acceptsOnlySecureSameHostSessionCookies() {
|
||||
@Test fun acceptsOnlyAllowListedSameHostCookies() {
|
||||
val jar = NativeCookies(server)
|
||||
jar.accept(listOf("yuvomi.sid=valid; Secure; HttpOnly; Path=/; Max-Age=60"))
|
||||
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())
|
||||
}
|
||||
|
||||
@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() {
|
||||
val now = System.currentTimeMillis()
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
| Application state | AndroidViewModel + Compose state | MainActor ObservableObject |
|
||||
| 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 |
|
||||
| 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.
|
||||
- Native UI mutations wait for the server result. Permissions remain enforced
|
||||
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
|
||||
remote revocation. Logout attempts server revocation and still erases the local
|
||||
session if the server cannot be reached.
|
||||
|
||||
@@ -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
|
||||
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 --cookie-without-secure
|
||||
```
|
||||
|
||||
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
|
||||
dashboard screenshot under `app/build/outputs/native-screenshots/`. It does not
|
||||
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
|
||||
`outputs/connected_android_test_additional_output/integrationAndroidTest/` before
|
||||
uninstalling the fixture. The harness rejects missing/ambiguous output and removes
|
||||
|
||||
+13
-1
@@ -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
|
||||
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;
|
||||
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
|
||||
slices; Rosetta/Homebrew installation is not required by the workflow. Build,
|
||||
|
||||
+88
-4
@@ -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**.
|
||||
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
|
||||
correction. Their private backup is `/home/neste/yuvomi-signing/initial-20261021`.
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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.
|
||||
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
|
||||
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
|
||||
|
||||
1. Typecheck/build the iOS app against Apple SDKs, run its actual XCTest/UI tests,
|
||||
and produce an unsigned archive. Fix any failures; none of these ran locally.
|
||||
1. Extend the now-successful Apple SDK/simulator/archive validation beyond the
|
||||
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
|
||||
or device, plus the minimum-supported Android and current WebView matrix.
|
||||
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 |
@@ -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.
|
||||
@@ -4,6 +4,11 @@
|
||||
This report closes the initial native rebuild slice, not the requested complete
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
[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
|
||||
|
||||
The Android workflow offers `android-native-debug-apk`; the Mac workflow bootstraps
|
||||
verified ARM64 Node before checkout. Actual artifact publication and Apple build
|
||||
results require remote-run inspection. Remote Ubuntu emulator jobs need KVM access;
|
||||
local emulator success is not a substitute for a passing remote matrix.
|
||||
Android build job 1059 succeeded and uploaded `android-native-debug-apk`. The actual
|
||||
artifact was downloaded, signature/version checked, installed and launched on the
|
||||
authorized local emulator. [Download the verified artifact](https://git.nesterovic.cc/nessi/Yuvomi-App/actions/runs/660/artifacts/android-native-debug-apk).
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -19,6 +19,10 @@ Use these states honestly:
|
||||
- **Parity accepted**: requires functional and visual evidence on both platforms.
|
||||
**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
|
||||
|
||||
| Upstream page | Native status | Work still required before parity |
|
||||
|
||||
+158
-7
@@ -8,7 +8,129 @@ tests are in `androidTestNative` / `androidTestNativeIntegration`, JVM native po
|
||||
tests, and Swift production contracts/transport tests. Archived WebView tests are
|
||||
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 |
|
||||
| --- | --- |
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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-swift.log`.
|
||||
|
||||
No Apple SDK application build, simulator UI test or physical-device result is
|
||||
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
|
||||
failed KVM preflight; the prerequisite was not weakened or counted as passing.
|
||||
## Observed Gitea results for source `40a7962`
|
||||
|
||||
- Android run #5 (global run 660), build job 1059: **success**, including packages,
|
||||
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
|
||||
|
||||
@@ -75,7 +226,7 @@ devices are not test targets.
|
||||
|
||||
## 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,
|
||||
complete module and settings parity, visual baselines, accessibility and human
|
||||
security review remain open. Missing modules are listed in
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -17,9 +17,7 @@ case "${1:-}" in
|
||||
ios)
|
||||
[[ "$(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'
|
||||
xcodebuild -version
|
||||
xcrun --find swift >/dev/null
|
||||
xcrun simctl list devices available >/dev/null
|
||||
python3 "${BASH_SOURCE[0]%/*}/check-apple-tools.py"
|
||||
;;
|
||||
*) fail 'usage: bash scripts/check-ci-runner.sh android|emulator|ios' ;;
|
||||
esac
|
||||
@@ -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.`);
|
||||
@@ -13,15 +13,24 @@ process.env.PORT = '0';
|
||||
process.env.BIND_ADDRESS = '127.0.0.1';
|
||||
process.env.DB_PATH = path.join(directory, 'fixture.db');
|
||||
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.NODE_ENV = 'test';
|
||||
process.env.LOG_LEVEL = 'warn';
|
||||
const tls = { key: fs.readFileSync(path.join(directory, 'key.pem')), cert: fs.readFileSync(path.join(directory, 'cert.pem')) };
|
||||
const idp = await startIdentityProvider(tls);
|
||||
process.env.OIDC_ISSUER = idp.issuer;
|
||||
process.env.OIDC_CLIENT_ID = idp.clientId;
|
||||
process.env.OIDC_CLIENT_SECRET = idp.secret;
|
||||
const idp = process.env.YUVOMI_TEST_DISABLE_OIDC === 'true' ? null : await startIdentityProvider(tls);
|
||||
if (idp) {
|
||||
process.env.OIDC_ISSUER = idp.issuer;
|
||||
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')));
|
||||
if (!server.listening) await once(server, 'listening');
|
||||
let networkAvailable = true;
|
||||
@@ -47,7 +56,9 @@ const proxy = https.createServer(tls, (request, response) => {
|
||||
});
|
||||
proxy.listen(0, '127.0.0.1');
|
||||
await once(proxy, 'listening');
|
||||
process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`;
|
||||
idp.setRedirect(process.env.OIDC_REDIRECT_URI);
|
||||
fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp.port }));
|
||||
process.on('SIGTERM', () => { idp.close(); proxy.close(); server.close(); process.exit(0); });
|
||||
if (idp) {
|
||||
process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`;
|
||||
idp.setRedirect(process.env.OIDC_REDIRECT_URI);
|
||||
}
|
||||
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); });
|
||||
@@ -1,24 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test runner prerequisite decisions with fake tools, not actual Apple SDKs."""
|
||||
import os
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
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):
|
||||
def run_check(self, mode, system="Linux", architecture="x86_64", missing=None, broken_xcode=False):
|
||||
with tempfile.TemporaryDirectory(prefix="yuvomi-runner-test-") as temporary:
|
||||
def run_check(self, mode, system="Linux", architecture="x86_64", missing=None, broken_xcode=False, broken_xcrun=False):
|
||||
base = "/tmp/opencode" if Path("/tmp/opencode").exists() else None
|
||||
with tempfile.TemporaryDirectory(prefix="yuvomi-runner-test-", dir=base) as temporary:
|
||||
directory = Path(temporary)
|
||||
for name in ["git", "curl", "unzip", "python3", "xcodebuild", "xcrun"]:
|
||||
if name == missing:
|
||||
continue
|
||||
path = directory / name
|
||||
status = 1 if name == "xcodebuild" and broken_xcode else 0
|
||||
path.write_text(f"#!/bin/sh\nexit {status}\n")
|
||||
status = 1 if (name == "xcodebuild" and broken_xcode or name == "xcrun" and broken_xcrun) else 0
|
||||
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)
|
||||
uname = directory / "uname"
|
||||
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):
|
||||
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__":
|
||||
unittest.main()
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Regressions for AGP screenshot output collection, not visual parity tests."""
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -57,6 +58,42 @@ class ScreenshotEvidenceTests(unittest.TestCase):
|
||||
self.assertFalse((self.screenshots / image.name).exists())
|
||||
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__":
|
||||
unittest.main()
|
||||
+33
-13
@@ -20,28 +20,44 @@ import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
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):
|
||||
additional = app_output / "connected_android_test_additional_output/integrationAndroidTest"
|
||||
screenshots = app_output / "native-screenshots"
|
||||
# Never accept a previous run's screenshot as current evidence.
|
||||
for stale in additional.rglob("native-dashboard.png"):
|
||||
for name in VISUAL_OUTPUTS:
|
||||
for stale in additional.rglob(name):
|
||||
stale.unlink()
|
||||
(screenshots / "native-dashboard.png").unlink(missing_ok=True)
|
||||
(screenshots / name).unlink(missing_ok=True)
|
||||
return additional, screenshots
|
||||
|
||||
|
||||
def collect_native_screenshot(additional, screenshots):
|
||||
captures = list(additional.rglob("native-dashboard.png"))
|
||||
assert len(captures) == 1, "Missing or ambiguous native fixture screenshot"
|
||||
def collect_native_screenshot(additional, screenshots, gallery=False):
|
||||
names = VISUAL_OUTPUTS if gallery else ("native-dashboard.png",)
|
||||
validated = {}
|
||||
parents = set()
|
||||
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 / "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()
|
||||
assert revision == REVISION, f"Expected pinned upstream {REVISION}, got {revision}"
|
||||
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:
|
||||
process = subprocess.Popen(["node", str(ROOT / "scripts/integration-server.mjs"), str(upstream), temporary],
|
||||
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:
|
||||
for _ in range(200):
|
||||
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})
|
||||
assert status == 200
|
||||
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"))
|
||||
csrf = json.loads(body)["csrfToken"]
|
||||
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", {}, {"X-CSRF-Token": csrf})[0] == 200
|
||||
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
|
||||
status, body, _ = request("/api/v1/auth/me")
|
||||
identity = json.loads(body)
|
||||
@@ -150,8 +168,8 @@ def run(upstream, android):
|
||||
raise RuntimeError("Android integration tests failed. Inspect the Gradle test report.")
|
||||
reports = app_output / "androidTest-results/connected/integration"
|
||||
subprocess.run(["python3", str(ROOT / "scripts/verify-test-results.py"),
|
||||
str(reports), "--minimum", "7"], check=True)
|
||||
collect_native_screenshot(additional, screenshots)
|
||||
str(reports), "--minimum", "10"], check=True)
|
||||
collect_native_screenshot(additional, screenshots, gallery=True)
|
||||
finally:
|
||||
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{port}"], 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.add_argument("upstream", type=Path)
|
||||
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()
|
||||
run(arguments.upstream.resolve(), arguments.android)
|
||||
run(arguments.upstream.resolve(), arguments.android, arguments.cookie_without_secure)
|
||||
Reference in new issue
Block a user