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